cal-invite 0.2.1 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acfc7fb8536f80559ceccfb16ab9d19a79d3b29ee7d5dc082859d5676e600951
4
- data.tar.gz: 244cac5a83e5874bdc037bb13d8550fae3691acddac5403a5fe98714d726f9d6
3
+ metadata.gz: af172acc91308a8bcfdf60019e87a5e01fb8f23baa9287b7d1d2c72b282f58be
4
+ data.tar.gz: dd38efbb8511d896e5d6f9c427f060ea8b1a04e7c6b8561b790ff14cdf63effe
5
5
  SHA512:
6
- metadata.gz: '0318e32174bf7946585e98498661c76c2470b05c06a17fa08a469c04ec006485613af7b70bf874c195405eb079e3d993d3ec43a54fe4e2ee457773245c782b65'
7
- data.tar.gz: e328f2f90f38e8104f2180ce79b14cdf12868817d936e28dc52e561aef9c3f58c9f0deb06de35c3173512c8fa78f083229a49f218e6ab1130402c477fcf2da06
6
+ metadata.gz: 82b6aeffacee1b90bce91595a28d864f879d09be68a9503abf3290529ca738080cf90fd4cb7c5dec3d641789c6ea4c36d7193aa8b9d76a7605ed510f72c0a59f
7
+ data.tar.gz: 6663085d67c0376b52556b6d09a5a575f8bb8005f6276719e27d1acf3233c0087a656b56df4bf29a835230db602048c3759cf31f3fc9912c0c2509f89e58bb31
data/AGENTS.md CHANGED
@@ -21,26 +21,31 @@ Tests are Minitest-based, files live under `test/cal_invite/`, named `*_test.rb`
21
21
 
22
22
  ## Architecture
23
23
 
24
- - **`CalInvite` module** (`lib/cal_invite.rb`) — top-level entry point. Holds a singleton `CalInvite.configuration` (a `Configuration` instance) set via `CalInvite.configure { |c| ... }`. Mixes in `Caching` at the module level for class-level cache helpers.
24
+ - **`CalInvite` module** (`lib/cal_invite.rb`) — top-level entry point. Holds a singleton `CalInvite.configuration` (a `Configuration` instance) set via `CalInvite.configure { |c| ... }`.
25
25
 
26
- - **`CalInvite::Event`** (`lib/cal_invite/event.rb`) — the central object. Holds all event attributes (title, start/end time, location, url, attendees, timezone, notes, multi-day sessions, all-day flag). `generate_calendar_url(provider)` is the main API: it validates the event, checks the cache (MD5 hash of all attributes + provider as key), and if not cached, resolves `CalInvite::Providers::<CapitalizedProvider>`, instantiates it with `self`, and calls `#generate`. Location and virtual-meeting URL are intentionally separate fields (not merged) so each provider can format them appropriately.
26
+ - **`CalInvite::Event`** (`lib/cal_invite/event.rb`) — the central object. Holds all event attributes (title, start/end time, location, url, attendees, timezone, notes, multi-day sessions, all-day flag). `generate_calendar_url(provider)` is the main API: it validates the event, resolves `CalInvite::Providers::<CapitalizedProvider>`, instantiates it with `self`, and calls `#generate`. The gem deliberately caches **nothing** (output caching and the cache helpers were removed in 0.3.0; see CONFIGURATION.md's `generate_calendar_url` section for why) — don't reintroduce it. Location and virtual-meeting URL are intentionally separate fields (not merged) so each provider can format them appropriately.
27
27
 
28
28
  - **`CalInvite::Providers`** (`lib/cal_invite/providers.rb`) — namespace with `SUPPORTED_PROVIDERS = %i[google ical outlook yahoo ics office365]`. Provider classes are autoloaded from `lib/cal_invite/providers/*.rb`. To add a new provider: create a class extending `BaseProvider`, implement `#generate`, register it here with `autoload`, and add its symbol to `SUPPORTED_PROVIDERS`.
29
29
 
30
- - **`BaseProvider`** (`lib/cal_invite/providers/base_provider.rb`) — abstract base class (note: it's a top-level `class BaseProvider`, not namespaced under `CalInvite::Providers`). Defines the `#generate` contract (must raise `NotImplementedError` if not overridden) plus shared helpers used by subclasses: `url_encode`, `format_description`, `format_location`, `format_url`, `format_description_with_url`, `add_optional_params`, `attendees_list`. Each concrete provider (`Google`, `Outlook`, `Office365`, `Yahoo`, `Ical`, `Ics`) builds a provider-specific URL or content string from the `Event` using these helpers.
30
+ - **`CalInvite::Providers::BaseProvider`** (`lib/cal_invite/providers/base_provider.rb`) — abstract base class. A deprecated top-level `::BaseProvider` alias (defined in `providers.rb` via `Object.deprecate_constant`, skipped if the host app already has a `BaseProvider`) remains until 0.4.0 — don't reference it from gem code. Defines the `#generate` contract (must raise `NotImplementedError` if not overridden) plus shared helpers used by subclasses: URL-building (`url_encode`, `format_description`, `format_location`, `format_url`, `format_description_with_url`, `add_optional_params`) and iCalendar property formatting (`attendees_list`, `attendee_emails`, `attendee_hash_value`, `attendee_line`, `organizer_line`, `method_value`, `status_line`, `geo_line`, `transp_line`, `busystatus_line`, `class_line`, `importance_lines`, `disallow_counter_line`, `rrule_line`, `valarm_lines`, `local_wall_time`, `vtimezone_lines`). `attendee_hash_value(attendee, key)` is the correct way to read an attendee-hash field (tries symbol then string key) — don't reintroduce `attendee[:key] || attendee["key"]`, which silently drops explicit `false` values. Each concrete provider (`Google`, `Outlook`, `Office365`, `Yahoo`, `Ical`, `Ics`) builds a provider-specific URL or content string from the `Event` using these helpers.
31
31
 
32
- - **`Ics` / `IcsContent` providers** (`ics.rb`, `ics_content.rb`) — generate actual `.ics` file content (RFC 5545) rather than a URL, for direct download flows (see README's `send_data` controller example). `IcsDownload` is aliased to `IcsContent` via autoload.
32
+ - **`Ics` / `IcsContent` providers** (`ics.rb`, `ics_content.rb`) — generate actual `.ics` file content (RFC 5545) rather than a URL, for direct download flows (see README's `send_data` controller example). `Ics` is the **only** `.ics` generator: `Ical` and `IcsContent` are empty subclasses kept for their public names, so make content changes in `ics.rb` only. `IcsDownload` (HTTP header/filename helpers) lives in `ics_content.rb` alongside `IcsContent` (not an alias). All ics-family providers accept a `method:` (`:publish`/`:request`/`:cancel`/`:reply`/`:counter`/`:decline_counter`) passed through `Event#generate_calendar_url`. Use `BaseProvider#method_value` (not `method.to_s.upcase`) when emitting the `METHOD:` line — `:decline_counter` must render as `METHOD:DECLINECOUNTER` (one word) per RFC 5545, unlike its Ruby symbol. `:request` plus `Event#organizer` produces an RFC 5545 meeting request that mail clients render with RSVP actions; `:cancel` (reusing the same `Event#uid`, with `Event#sequence` bumped) produces a real cancellation; `:reply`/`:counter`/`:decline_counter` all omit `RSVP=TRUE` on `ATTENDEE` lines (none of them request a further response). `Event#uid` is stable (auto-generated and memoized per instance, or pass your own) — required so a later `:request`/`:cancel` is recognized as updating the original invite rather than creating a new one. `Event#attendees` entries can be plain email strings or `{ email:, name:, partstat:, rsvp: }` hashes (`partstat:` drives `PARTSTAT=` on the `ATTENDEE` line, default `NEEDS-ACTION`; `rsvp:` overrides the method-derived `RSVP=TRUE`/absent default — needed for e.g. a registration-confirmation invite where the attendee is already `:accepted` and `rsvp: false` suppresses it under `method: :request`). Optional `Event` fields `geo`, `reminders`, `busy`, `visibility`, `rrule`, `calendar_name` map to `GEO:`, `VALARM`, `TRANSP:`, `CLASS:`, `RRULE:`, and `X-WR-CALNAME` respectively; `importance`/`allow_counter` map to Outlook's non-standard `X-MICROSOFT-CDO-IMPORTANCE`/`X-MICROSOFT-DISALLOW-COUNTER` (plus standard `PRIORITY:` and `X-MICROSOFT-CDO-BUSYSTATUS` alongside `TRANSP:`) — safe to always emit since unrecognized `X-` properties are required to be ignored by other clients. See `CONFIGURATION.md`.
33
+ - **Timezones** — `:ics`/`:ical` build a real `VTIMEZONE` (via `CalInvite::IcalTimezone`, backed by `tzinfo`) for any recognized IANA `timezone` other than `'UTC'`, and correctly convert `start_time`/`end_time` (always UTC) to that zone's local wall-clock time for `DTSTART;TZID=...`. `'UTC'` uses plain `Z`-suffixed timestamps with no `VTIMEZONE`. `Event#validate!` rejects any other `timezone` (`IcalTimezone.valid?`), so providers can rely on it being UTC or a real IANA zone.
33
34
 
34
- - **`Caching`** (`lib/cal_invite/caching.rb`) — an `ActiveSupport::Concern` mixed into `CalInvite` at the class level. Provides `fetch_from_cache`, `write_to_cache`, `read_from_cache`, `clear_cache!`, `clear_event_cache!`, `clear_provider_cache!`, all keyed under `CalInvite.configuration.cache_prefix`. Works with any `ActiveSupport::Cache::Store`-compatible store, or a custom object responding to `read`/`write`/`delete`. See also `CACHING.md`.
35
-
36
- - **`Configuration`** (`lib/cal_invite/configuration.rb`) — holds `cache_store`, `cache_prefix` (default `'cal_invite'`), `cache_expires_in` (default 24 hours), `webhook_secret`, `timezone` (default `'UTC'`). `cache_store=` accepts `:memory_store`, `:null_store`, or any object implementing `read`/`write`/`delete`.
35
+ - **`Configuration`** (`lib/cal_invite/configuration.rb`) — holds `webhook_secret` and `timezone` (default `'UTC'`; the default `Event#timezone` when `timezone:` isn't passed). `cache_store`/`cache_prefix`/`cache_expires_in` are deprecated no-ops kept until 0.4.0: their setters warn (`uplevel: 2`, so the warning points at the caller's initializer) but still store the value so app code reading them keeps working.
37
36
 
38
37
  ## Conventions
39
38
 
40
39
  - Always pass times to `Event.new` in UTC; use the `timezone` attribute only to control display/formatting in the generated invite.
40
+ - **Treat every `Event` attribute as untrusted input** (host apps pass end-user strings: titles, display names, ...). In `.ics` output: TEXT values go through `Ics#escape_text`; parameter values (e.g. `CN=`) through `BaseProvider#quoted_param` (RFC 6868); enumerated/numeric values are whitelisted or coerced (`class_line`, `geo_line`, `sequence_line`); and `Ics#ensure_single_line_values!` raises on any CR/LF left in a content line — never bypass it or interpolate a raw attribute into a property/parameter. URL providers must pass every value through `url_encode`. Add a case to `test/cal_invite/ics_security_test.rb` for any new property.
41
41
  - `location` (physical) and `url` (virtual meeting link) are kept as distinct fields throughout the provider layer — don't conflate them when adding provider logic.
42
- - All-day events skip `start_time`/`end_time` validation; multi-day events use the separate `multi_day_sessions` array instead of `start_time`/`end_time`.
42
+ - All-day events skip `start_time`/`end_time` validation; multi-day events use the separate `multi_day_sessions` array instead of `start_time`/`end_time`. `Event#start_time`/`#end_time` are readers that fall back to the earliest session start / latest session end when not set explicitly (computed on read, so they follow `update_attributes`), which is what single-range URL providers use.
43
43
  - YARD-style doc comments (`@param`, `@return`, `@example`) are used throughout `lib/` and feed the RDoc-generated docs published to GitHub Pages (`.github/workflows/documentation.yml`) — keep them accurate when changing public method signatures.
44
+ - CalInvite is outbound-only: it renders `.ics` content/URLs and has no concept of inbound RSVP replies. It also can't express Google/Microsoft Calendar-API-only concepts like guest permissions (`guestsCanInviteOthers` etc.) since those have no `.ics`/URL representation at all — but it does generate the genuinely protocol-level equivalents that exist: `METHOD:COUNTER`/`DECLINECOUNTER` and Outlook's `X-MICROSOFT-*` extensions (`allow_counter`, `importance`, `busy`). See CONFIGURATION.md's "Tracking RSVPs" and "Guest permissions" sections for the full breakdown of what's real vs. not addable, and why.
45
+
46
+ ## Example app (`Example/calendar_app`)
47
+
48
+ Rails 8.1 app demonstrating the gem. Its Gemfile depends on the **published** `cal-invite` gem (never commit it pointing at `path: "../.."`) — when testing example-app changes that rely on unreleased gem code, temporarily uncomment the `path: "../.."` line locally, `bundle install`, test, then revert the Gemfile and re-run `bundle install` against rubygems before committing. `Meeting`/`MeetingAttendee` (with a migration) is a working reference implementation of the `uid`/`sequence` persistence pattern CONFIGURATION.md describes — see `app/models/meeting.rb`, `app/controllers/meetings_controller.rb` (create/reschedule/cancel, plus `show` serving a cached `.ics` with `stale?` + `Rails.cache` per CACHING.md), and `app/controllers/event_replies_controller.rb` (illustrative inbound `METHOD:REPLY` parsing, not wired to a real inbound-email provider).
44
49
 
45
50
  ## PRs & commits
46
51
 
data/CACHING.md CHANGED
@@ -1,148 +1,240 @@
1
- ## Caching
1
+ # Caching CalInvite output in your application
2
2
 
3
- CalInvite supports flexible caching options when used within a Rails application. You can configure the cache store to use any of Rails' supported cache stores including memory store, Redis, or a custom cache store implementation.
3
+ CalInvite doesn't cache anything (see the [0.3.0 changelog](CHANGELOG.md)). Building a URL or `.ics` file is in-memory string work that takes microseconds, and the gem can't know when your data changes. Your application does, so caching belongs there.
4
4
 
5
- ### Basic Configuration
5
+ This guide shows a few ways to do it in plain Ruby, Rails, Sinatra, Hanami and Grape. It isn't exhaustive. Every example was run against the framework version named in its section.
6
6
 
7
- Configure caching in your Rails application's initializer:
7
+ ## Contents
8
+
9
+ - [Do you need a cache?](#do-you-need-a-cache)
10
+ - [Four rules for correct caching](#four-rules-for-correct-caching)
11
+ - [The example model](#the-example-model)
12
+ - [Plain Ruby](#plain-ruby)
13
+ - [Rails 8.1](#rails-81)
14
+ - [Sinatra](#sinatra)
15
+ - [Hanami](#hanami)
16
+ - [Grape](#grape)
17
+
18
+ ## Do you need a cache?
19
+
20
+ Usually not for the output itself. Caching it pays off in two cases:
21
+
22
+ - **Repeated downloads of the same `.ics`.** Calendar apps that subscribe to a feed poll it every few minutes to hours. HTTP caching (`ETag`/`Last-Modified` and a `304 Not Modified` reply) saves the bandwidth and lets the client skip re-importing an unchanged file. This is the most useful kind of caching for calendar files.
23
+ - **Expensive work before the gem runs.** If building the `Event` means loading a meeting, its attendees and its venue from the database, cache the result to skip those queries. CalInvite's own share of the time is negligible.
24
+
25
+ Don't cache invites you send by email (`method: :request`, `:cancel`, ...). Each send should be generated when it's sent; see rule 3.
26
+
27
+ ## Four rules for correct caching
28
+
29
+ 1. **Key on the data's version, not on the output.** The key needs the record's identity, something that changes whenever the record changes (`updated_at`, a version column, the invite `sequence`), and every input that changes the output: provider, `method:`, and anything that varies per viewer, such as whether attendees are shown.
30
+ 2. **Persist the event's `uid`.** Without an explicit `uid:`, each `Event` gets a random one, so two builds of the same meeting produce different `.ics` content. Store the `uid` on your record and pass it in.
31
+ 3. **Never build an `ETag` by hashing the `.ics` content.** Every generation writes the current time into `DTSTAMP`, so the hash changes on every request and the client never gets a `304`. Derive the `ETag` from the record's version. A cached `.ics` also keeps the `DTSTAMP` from when it was cached. That's harmless for downloads and feeds, but it's another reason to generate emailed invites fresh.
32
+ 4. **Include the configured timezone if it can change at runtime.** `CalInvite.configuration.timezone` is the default `timezone` for events. Most apps set it once at boot, so it doesn't need to be in the key.
33
+
34
+ ## The example model
35
+
36
+ The plain Ruby, Sinatra, Hanami and Grape examples use this stand-in for a database record. In Rails it's an Active Record model with the same `to_cal_invite_event` method.
8
37
 
9
38
  ```ruby
10
- # config/initializers/cal_invite.rb
11
- CalInvite.configure do |config|
12
- # Use Rails.cache by default
13
- config.cache_store = Rails.cache
14
-
15
- # Optional: Set a custom prefix for cache keys
16
- config.cache_prefix = 'my_app_cal_invite'
17
-
18
- # Optional: Set cache expiration (in seconds)
19
- config.cache_expires_in = 3600 # 1 hour
39
+ require "cal_invite"
40
+
41
+ Meeting = Struct.new(:id, :title, :starts_at, :ends_at, :uid, :sequence, :updated_at, keyword_init: true) do
42
+ def to_cal_invite_event
43
+ CalInvite::Event.new(title: title, start_time: starts_at, end_time: ends_at, uid: uid, sequence: sequence)
44
+ end
45
+
46
+ # Changes whenever the meeting changes (rule 1).
47
+ def cache_version
48
+ "#{id}-#{updated_at.to_i}-#{sequence}"
49
+ end
20
50
  end
51
+
52
+ MEETINGS = {
53
+ 1 => Meeting.new(id: 1, title: "Design review", uid: "meeting-1@example.com", sequence: 0,
54
+ starts_at: Time.utc(2026, 11, 2, 15), ends_at: Time.utc(2026, 11, 2, 16),
55
+ updated_at: Time.utc(2026, 10, 20, 9))
56
+ }
21
57
  ```
22
58
 
23
- ### Available Cache Stores
59
+ ## Plain Ruby
24
60
 
25
- CalInvite supports the following cache stores:
61
+ Written for Ruby 3.4 and run on Ruby 4.0. Ruby 4.0 doesn't add anything that changes these patterns; see [Ruby 4.0](#ruby-40) below.
26
62
 
27
- 1. Memory Store (default)
28
- ```ruby
29
- config.cache_store = :memory_store
30
- ```
63
+ ### A small in-process LRU cache
31
64
 
32
- 2. Null Store (for disabling caching)
33
- ```ruby
34
- config.cache_store = :null_store
35
- ```
65
+ A `Hash` keeps insertion order, so deleting and re-inserting a key on each hit makes the first key the least recently used one. A `Mutex` keeps it safe under threaded servers like Puma.
36
66
 
37
- 3. Custom Cache Store
38
67
  ```ruby
39
- # Any object that implements read/write/delete methods
40
- config.cache_store = MyCacheStore.new
41
- ```
68
+ class IcsCache
69
+ def initialize(max_entries: 1_000)
70
+ @max_entries = max_entries
71
+ @entries = Hash.new(capacity: max_entries) # Ruby 3.4+: allocate the table once
72
+ @lock = Mutex.new
73
+ end
42
74
 
43
- 4. Rails Cache
44
- ```ruby
45
- # Use your Rails application's configured cache
46
- config.cache_store = Rails.cache
75
+ def fetch(key)
76
+ @lock.synchronize do
77
+ return @entries[key] = @entries.delete(key) if @entries.key?(key) # move to most recent
78
+ end
79
+
80
+ value = yield # generate outside the lock
81
+ @lock.synchronize do
82
+ @entries[key] = value
83
+ @entries.shift while @entries.size > @max_entries # evict least recently used
84
+ end
85
+ value
86
+ end
87
+ end
88
+
89
+ ICS_CACHE = IcsCache.new
90
+
91
+ def meeting_ics(meeting)
92
+ ICS_CACHE.fetch([meeting.cache_version, :ics, :publish]) do
93
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
94
+ end
95
+ end
47
96
  ```
48
97
 
49
- ### Cache Configuration Options
98
+ When a meeting changes, its `cache_version` changes, so the next call misses and the old entry ages out. Nothing has to be deleted by hand.
50
99
 
51
- - `cache_store`: The storage mechanism for cached data
52
- - `cache_prefix`: Prefix for all cache keys (default: 'cal_invite')
53
- - `cache_expires_in`: Default cache expiration time in seconds
54
- - `timezone`: Default timezone for cache keys (default: 'UTC')
100
+ Each server process has its own copy of this cache. That's fine for a single process or a small app. If several processes or machines should share one cache, use a shared store such as Redis.
55
101
 
56
- ### Custom Cache Adapters
102
+ ### ActiveSupport cache stores
57
103
 
58
- You can implement custom cache adapters by creating a class that responds to the following methods:
104
+ CalInvite already depends on `activesupport`, so its cache stores are available without Rails. `MemoryStore` adds a size limit and expiry to the pattern above, and the same `fetch` call works with `RedisCacheStore` (add the `redis` gem) or `FileStore` when processes need to share a cache.
59
105
 
60
106
  ```ruby
61
- class CustomCacheStore
62
- def read(key)
63
- # Implementation for retrieving cached value
64
- end
107
+ require "active_support"
65
108
 
66
- def write(key, value, options = {})
67
- # Implementation for storing value in cache
68
- # options may include :expires_in
69
- end
109
+ STORE = ActiveSupport::Cache::MemoryStore.new(size: 32 * 1024 * 1024, expires_in: 3600)
70
110
 
71
- def delete(key)
72
- # Implementation for removing cached value
111
+ def meeting_ics_from_store(meeting)
112
+ STORE.fetch(["meeting-ics", meeting.cache_version]) do
113
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
73
114
  end
115
+ end
116
+ ```
117
+
118
+ On ActiveSupport 7.x and earlier, loading the cache stores also loads some of ActiveSupport's core extensions. ActiveSupport 8 doesn't.
119
+
120
+ ### Ruby 4.0
121
+
122
+ Nothing in Ruby 4.0 improves this kind of caching. The headline changes are the `Ractor::Port` API, `Set` becoming a core class, and the experimental `Ruby::Box` isolation. The `Mutex`-guarded `Hash` above can't be shared between Ractors, but web servers and frameworks run requests on threads or processes rather than Ractors, so this doesn't affect normal applications. Both examples above run unchanged on 4.0.
123
+
124
+ ## Rails 8.1
125
+
126
+ Tested with Rails 8.1. `Rails.cache` is the cache store (Solid Cache by default since Rails 8.0), and `stale?` handles conditional GET: it sets `ETag` and `Last-Modified` from the record and answers `304 Not Modified` when the client's copy is current. Rails already knows the `text/calendar` MIME type as `:ics`.
127
+
128
+ ```ruby
129
+ # config/routes.rb
130
+ resources :meetings, only: :show
74
131
 
75
- def clear
76
- # Implementation for clearing all cached values
132
+ # app/models/meeting.rb
133
+ class Meeting < ApplicationRecord
134
+ def to_cal_invite_event
135
+ CalInvite::Event.new(title: title, start_time: starts_at, end_time: ends_at, uid: uid, sequence: sequence)
77
136
  end
78
137
  end
79
138
 
80
- # Configure CalInvite to use your custom cache store
81
- CalInvite.configure do |config|
82
- config.cache_store = CustomCacheStore.new
139
+ # app/controllers/meetings_controller.rb
140
+ class MeetingsController < ApplicationController
141
+ def show
142
+ meeting = Meeting.find(params[:id])
143
+
144
+ respond_to do |format|
145
+ format.ics do
146
+ # ETag from meeting.cache_key_with_version, Last-Modified from updated_at (rule 3)
147
+ if stale?(meeting, public: true)
148
+ ics = Rails.cache.fetch([meeting, :ics, :publish]) do
149
+ meeting.to_cal_invite_event.generate_calendar_url(:ics)
150
+ end
151
+ send_data ics, type: :ics, disposition: "inline", filename: "meeting-#{meeting.id}.ics"
152
+ end
153
+ end
154
+ end
155
+ end
83
156
  end
84
157
  ```
85
158
 
86
- ### Automatic Cache Invalidation
159
+ `GET /meetings/1.ics` returns the file, and a repeat request with the `ETag` it got back returns `304`. Passing `meeting` in the cache key uses its `cache_key_with_version`, which changes with `updated_at`, so updating the record (including bumping `sequence`) also refreshes the cached copy. If `sequence` lives somewhere that doesn't touch `updated_at`, add it to both the key and `stale?(etag: ...)`.
160
+
161
+ The [example app](Example/calendar_app) has a complete version in `MeetingsController#show`, with tests. Its meetings list attendees, so it keeps `Cache-Control` private (no `public: true`), uses `belongs_to :meeting, touch: true` so an attendee's RSVP change expires the cache, and bumps `sequence` with `increment!(:sequence, touch: true)`, because `increment!` alone doesn't update `updated_at`.
87
162
 
88
- The cache is automatically invalidated in the following scenarios:
163
+ ## Sinatra
89
164
 
90
- 1. When event attributes are updated via `update_attributes`
91
- 2. When a new calendar URL is generated
92
- 3. When the configuration changes
165
+ Tested with Sinatra 4.2. The `etag` and `last_modified` helpers stop the route with a `304` when the client's copy is current, so the `.ics` is only generated, or read from the cache, for clients that need it.
93
166
 
94
- ### Best Practices
167
+ ```ruby
168
+ require "sinatra/base"
169
+
170
+ class CalendarApp < Sinatra::Base
171
+ get "/meetings/:id.ics" do
172
+ meeting = MEETINGS.fetch(params[:id].to_i) { halt 404 }
95
173
 
96
- 1. **Cache Store Selection**: Choose an appropriate cache store based on your needs:
97
- - Use `:memory_store` for development and small applications
98
- - Use Rails.cache for production applications
99
- - Implement a custom cache store for specific requirements
174
+ cache_control :public, max_age: 300
175
+ last_modified meeting.updated_at
176
+ etag meeting.cache_version # halts with 304 if If-None-Match matches
100
177
 
101
- 2. **Cache Key Management**:
102
- - The gem automatically generates unique cache keys based on event attributes
103
- - Keys include a prefix for namespace isolation
104
- - Consider your timezone settings when debugging cache issues
178
+ content_type "text/calendar", charset: "utf-8"
179
+ meeting_ics(meeting) # the IcsCache helper from the plain Ruby section
180
+ end
181
+ end
182
+ ```
105
183
 
106
- 3. **Expiration Strategy**:
107
- - Set appropriate expiration times based on your use case
108
- - Consider using shorter expiration times for frequently changing data
109
- - Use `nil` expiration for permanent caching (until manual invalidation)
184
+ ## Hanami
110
185
 
111
- ### Example Rails Integration
186
+ Tested with hanami-action 3.0, the action library in Hanami 2.3 and later. Register the `ics` format once in `config/app.rb` with `config.actions.formats.register(:ics, "text/calendar")`. Hanami 2.2 and earlier used `formats.add`, which was removed in hanami-action 3.0.
112
187
 
113
- Here's a complete example of setting up caching in a Rails application:
188
+ `response.fresh` sets the `ETag`/`Last-Modified` headers and halts with `304` when the request is still fresh, before the `.ics` is built.
114
189
 
115
190
  ```ruby
116
- # config/initializers/cal_invite.rb
117
- CalInvite.configure do |config|
118
- if Rails.env.production?
119
- # Use Rails cache in production
120
- config.cache_store = Rails.cache
121
- config.cache_expires_in = 1.hour
122
- else
123
- # Use memory store in development
124
- config.cache_store = :memory_store
125
- config.cache_expires_in = 5.minutes
191
+ module Calendar
192
+ module Actions
193
+ module Meetings
194
+ class Show < Calendar::Action
195
+ def handle(request, response)
196
+ meeting = MEETINGS.fetch(request.params[:id].to_i) { halt 404 }
197
+
198
+ response.cache_control :public, max_age: 300
199
+ response.fresh etag: meeting.cache_version, last_modified: meeting.updated_at
200
+
201
+ response.format = :ics
202
+ response.body = meeting_ics(meeting)
203
+ end
204
+ end
205
+ end
126
206
  end
127
-
128
- config.cache_prefix = "cal_invite:#{Rails.env}"
129
- config.timezone = 'UTC'
130
207
  end
131
208
  ```
132
209
 
133
- ### Testing with Caching
210
+ ## Grape
134
211
 
135
- When writing tests that involve caching:
212
+ Tested with Grape 4.0. Grape has no conditional-GET helper, so the endpoint compares `If-None-Match` itself. Registering an `ics` content type and a pass-through formatter makes `GET /meetings/1.ics` return the `.ics` string as-is, without JSON-encoding it.
136
213
 
137
214
  ```ruby
138
- # In your test setup
139
- CalInvite.configure do |config|
140
- config.cache_store = :memory_store
141
- config.cache_expires_in = 3600 # 1 hour in seconds
215
+ require "grape"
216
+
217
+ class CalendarAPI < Grape::API
218
+ content_type :ics, "text/calendar"
219
+ formatter :ics, ->(object, _env) { object }
220
+
221
+ resource :meetings do
222
+ get ":id" do
223
+ meeting = MEETINGS.fetch(params[:id].to_i) { error!("Not found", 404) }
224
+ etag = %("#{meeting.cache_version}")
225
+
226
+ header "ETag", etag
227
+ header "Cache-Control", "public, max-age=300"
228
+ if env["HTTP_IF_NONE_MATCH"] == etag
229
+ status 304
230
+ return ""
231
+ end
232
+
233
+ content_type "text/calendar; charset=utf-8"
234
+ meeting_ics(meeting)
235
+ end
236
+ end
142
237
  end
238
+ ```
143
239
 
144
- # Clear cache between tests
145
- def setup
146
- CalInvite.configuration.cache_store.clear
147
- end
148
- ```
240
+ The same API can still serve JSON from other endpoints. The `.ics` extension selects the formatter.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Cal Invite
2
2
 
3
+ ## [v0.3.1] - 2026-10-22
4
+
5
+ No changes to the library code; this release is packaging and documentation.
6
+
7
+ - **Smaller gem.** The package no longer includes `assets/` (README images) or the unused `lib/tasks/rdoc.rake`: 52 KB instead of 973 KB.
8
+ - **API reference fixed.** The published docs at https://dashapi-ai.github.io/cal-invite/ were missing `lib/cal_invite.rb` and every provider class (`Google`, `Outlook`, `Office365`, `Yahoo`, `Ics`, `Ical`, `IcsContent`, `IcsDownload`, `BaseProvider`), because the docs workflow's `lib/**/*.rb` glob only matched one directory level. The workflow now runs `rake rdoc` and also publishes the README, CONFIGURATION.md, CACHING.md and CHANGELOG.md.
9
+ - **New CACHING.md** on caching CalInvite output in your app (plain Ruby, Rails 8.1, Sinatra, Hanami, Grape), including HTTP caching for `.ics` feeds. The example app gains a cached `.ics` download (`MeetingsController#show`).
10
+ - **README rewritten** for 0.3.0, with accurate examples that run in plain Ruby.
11
+ - **Gem metadata:** the RubyGems changelog link pointed at a non-existent `master` branch; it now points at `main`, and a documentation link was added.
12
+
13
+ ## [v0.3.0] - 2026-10-22
14
+
15
+ ### Upgrading from 0.2.x
16
+
17
+ These changes can break existing callers:
18
+
19
+ - **Unknown `Event` attributes raise.** `Event.new` and `Event#update_attributes` raise `ArgumentError` (`Unknown attribute(s): locaton`) for keys that aren't `Event` attributes, instead of silently ignoring them. If you pass a whole params hash, slice it first. A rejected `update_attributes` leaves the event unchanged. (#10)
20
+ - **`timezone` is validated.** It must be `'UTC'` or an IANA identifier known to tzinfo (e.g. `'America/New_York'`); anything else, including raw offsets like `'+01:00'`, raises `ArgumentError`. Such values previously produced invalid `.ics` (a `TZID=` with no `VTIMEZONE`, times shifted by the offset) and could inject parameters. (#7)
21
+ - **Time order is validated.** `ArgumentError` when `end_time` isn't after `start_time` (all-day events may end on the same date), or a `multi_day_sessions` entry is missing a time or ends before it starts. (#9)
22
+ - **Provider names are validated.** `generate_calendar_url` raises `ArgumentError` listing the supported providers for anything outside `CalInvite::Providers::SUPPORTED_PROVIDERS` (`:ics_content` still works as an alias of `:ics`). Previously e.g. `:string` resolved Ruby's `String` and failed confusingly. (#11)
23
+ - **`config.timezone` is now honored** as the default `Event#timezone` when `timezone:` isn't passed, as documented. If you set it to something other than `'UTC'`, your events now render in that zone. An explicit `timezone:` still wins. (#22)
24
+ - **No more blanket `active_support/core_ext`.** Requiring `cal_invite` no longer monkey-patches core classes (`Integer#hours`, `Object#blank?`, `String#squish`, ...) in non-Rails apps. Plain-Ruby code that used those only because `cal_invite` loaded them must `require "active_support/core_ext"` (or the specific file). Rails apps are unaffected. This also lets the gem load with ActiveSupport 6.0 on Ruby 4.0. (#15)
25
+
26
+ ### Removed
27
+
28
+ - **Output caching.** `generate_calendar_url` builds its output fresh on every call and never touches a cache store; `update_attributes` no longer wipes every event's cache entries. The cache ignored `cache_prefix`, almost never hit, froze `.ics` `DTSTAMP`, and cost more than regenerating. Output is unchanged. To cache anyway, do it in your app; CONFIGURATION.md has an example. (#14)
29
+ - **Cache helpers.** `CalInvite.fetch_from_cache`, `write_to_cache`, `read_from_cache`, `clear_cache!`, `clear_event_cache!`, `clear_provider_cache!` and `CalInvite::Caching` are gone. They had never worked (every call raised `NoMethodError`). `CACHING.md` is gone too. (#32)
30
+
31
+ ### Deprecated (removed in 0.4.0)
32
+
33
+ - **`config.cache_store=`, `cache_prefix=`, `cache_expires_in=`** have no effect. They print `warning: [cal_invite] Configuration#cache_store= is deprecated and has no effect ...` pointing at your initializer line, but still store the value, so code that reads them keeps working. Delete them from your initializer. (#32)
34
+ - **`::BaseProvider`.** The provider base class is now `CalInvite::Providers::BaseProvider`, so the gem no longer owns a top-level constant that can collide with your app's. `::BaseProvider` remains as an alias (not defined at all if your app has its own `BaseProvider`). Ruby prints `constant ::BaseProvider is deprecated` when deprecation warnings are enabled (e.g. `RUBYOPT=-W:deprecated`). (#13)
35
+
36
+ ### Security
37
+
38
+ - **Fix content-line/parameter injection in `.ics` output** (`:ics`, `:ical`, `IcsContent`). A CR or LF in event attributes (title, description, attendee/organizer names and emails, `uid`, `rrule`, `visibility`, `geo`, ...), or a `"` in a display name, could add arbitrary properties to a generated invite (e.g. extra attendees, a different `METHOD:`). URL providers were not affected. (#5)
39
+ - TEXT values are escaped per RFC 5545 (backslashes were previously left unescaped and lone `\r` passed through).
40
+ - `CN=` display names are quoted and escaped per RFC 6868.
41
+ - `URL:` is no longer TEXT-escaped (it's a URI; commas and semicolons in links were being corrupted).
42
+ - Values that can't be escaped raise `ArgumentError` if they contain a line break. `visibility` must be `:public`/`:private`/`:confidential`, `geo` numeric, and `sequence` an integer.
43
+ - `SECURITY.md`: real policy (supported versions, private reporting via GitHub Security Advisories).
44
+
45
+ ### Added
46
+
47
+ - `organizer:` accepts a plain email string (`organizer: "jane@example.com"`), like `attendees`; a string previously crashed `.ics` generation with `TypeError`. String-keyed hashes are read too. (#12)
48
+ - Multi-day events no longer need top-level `start_time`/`end_time`: they default to the earliest session start and latest session end, so the README's multi-day example works. `:google`/`:outlook`/`:office365` show the overall span; `:ics`/`:ical`/`:yahoo` still emit one entry per session. Explicit times win. (#24)
49
+
50
+ ### Fixed
51
+
52
+ - `.ics` content lines longer than 75 octets are folded (RFC 5545 §3.1), never splitting a multi-byte UTF-8 character. Long lines could be mangled or rejected by Outlook and mail gateways. (#6)
53
+ - `'UTC'` events emit `Z`-suffixed `DTSTART`/`DTEND` instead of `TZID=UTC` with no `VTIMEZONE`. (#7)
54
+ - `:ical` and `IcsContent` now produce exactly the same content as `:ics`. `IcsContent` previously emitted UTC-only times with no `VTIMEZONE`, ignored `notes`, and didn't support all-day events. `:ics` all-day events without times default to today, as `:ical` did. (#4)
55
+ - Non-UTC timezones under tzinfo 1.x (Rails/ActiveSupport 6.0): `:ics`/`:ical` raised `NoMethodError` (`to_local`) and silently omitted `VTIMEZONE`. (#17)
56
+ - `Event.new` no longer mutates the attributes hash passed in (it used to delete several keys from it, so reusing the hash dropped them). (#8)
57
+
58
+ ### Docs
59
+
60
+ - README "Testing" section covers `rake test`, `bin/console`, and the Example app's local-path Gemfile workflow. `CLAUDE.md` merged into `AGENTS.md`.
61
+
3
62
  ## [v0.2.1] - 2026-08-10
4
63
 
5
64
  - `:google` provider: honor `Event#attendees`/`show_attendees` (`add=`), `Event#timezone` (`ctz=`), `Event#rrule` (`recur=`), and `Event#busy` (`crm=BUSY`/`AVAILABLE`) in the generated Google Calendar URL — these were already modeled on `Event` for the ics providers but never wired into `:google`.
@@ -56,7 +115,7 @@
56
115
 
57
116
  ## [v0.1.2] - 2024-12-17
58
117
 
59
- ⚠️ This version should not be used
118
+ **Warning:** This version should not be used
60
119
 
61
120
  - Add support to Microsoft office 365 calendar invite URL
62
121
  - Update the README
@@ -65,7 +124,7 @@
65
124
 
66
125
  ## [v0.1.1] - 2024-12-17
67
126
 
68
- ⚠️ This version should not be used
127
+ **Warning:** This version should not be used
69
128
 
70
129
  Fixing a bug in the gemspec file
71
130