recurable 1.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +311 -0
- data/lib/recurable/array_inclusion_validator.rb +13 -0
- data/lib/recurable/recurrence.rb +203 -0
- data/lib/recurable/recurrence_serializer.rb +13 -0
- data/lib/recurable/rrule_utils.rb +33 -0
- data/lib/recurable/version.rb +5 -0
- data/lib/recurable.rb +49 -0
- metadata +108 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4934fe3e2307e356b7cd8a4ec8be35c0ca8369d38dbf0c2fa883403f062c1b95
|
|
4
|
+
data.tar.gz: 51dfeca41d7311251e0b8e691a5237ccb241a5e3197da03d523f12d0e821156b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d1cff6b649fdbc7fba5dc7e644dc0ac0c6a7be9daa43d29b47d23a8eac04a4e99edbdd0b40c4f138d07e125ffe34e9d331fbe7464d9c4ceaf2d614284709af0d
|
|
7
|
+
data.tar.gz: 2614f0cdb426943ad623c7d797f304dae23566c41af691a014dcd7af9820b7b7669f6781c98dd4a09072690ce9c058344d535de1c417ed80c5ded46e98fbb08c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Rosenberg, Matt Lewis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# Recurable
|
|
2
|
+
|
|
3
|
+
iCal RRULE recurrence library for Ruby with optional Rails/ActiveRecord integration. Full [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10) RRULE support.
|
|
4
|
+
|
|
5
|
+
## Quick Start: Standalone
|
|
6
|
+
|
|
7
|
+
No Rails required. Just ActiveModel and ActiveSupport.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require 'recurable/recurrence'
|
|
11
|
+
|
|
12
|
+
# Build a recurrence from attributes
|
|
13
|
+
recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
|
|
14
|
+
recurrence.to_rrule # => "FREQ=DAILY;INTERVAL=1"
|
|
15
|
+
|
|
16
|
+
# Parse an existing RRULE string
|
|
17
|
+
recurrence = Recurrence.from_rrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
|
|
18
|
+
recurrence.frequency # => "WEEKLY"
|
|
19
|
+
recurrence.interval # => 2
|
|
20
|
+
recurrence.by_day # => ["MO", "WE", "FR"]
|
|
21
|
+
|
|
22
|
+
# Frequency predicates
|
|
23
|
+
recurrence.weekly? # => true
|
|
24
|
+
recurrence.daily? # => false
|
|
25
|
+
|
|
26
|
+
# Frequency comparison (YEARLY < MONTHLY < ... < MINUTELY)
|
|
27
|
+
yearly = Recurrence.new(frequency: 'YEARLY')
|
|
28
|
+
monthly = Recurrence.new(frequency: 'MONTHLY')
|
|
29
|
+
yearly < monthly # => true
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start: With Rails
|
|
33
|
+
|
|
34
|
+
Add the gem, then prepend the `Recurable` concern on any model with an `rrule` string column:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class Plan < ApplicationRecord
|
|
38
|
+
include Recurable
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
plan = Plan.new
|
|
42
|
+
plan.frequency = 'MONTHLY'
|
|
43
|
+
plan.interval = 3
|
|
44
|
+
plan.by_month_day = [15]
|
|
45
|
+
plan.rrule # => #<Recurrence> with to_rrule "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15"
|
|
46
|
+
plan.valid? # validates both the model and the recurrence
|
|
47
|
+
plan.monthly? # => true
|
|
48
|
+
plan.humanize_recurrence # => "every 3 months on the 15th"
|
|
49
|
+
|
|
50
|
+
# Time projection
|
|
51
|
+
plan.recurrence_times(
|
|
52
|
+
project_from: Time.zone.local(2026, 1, 1),
|
|
53
|
+
project_to: Time.zone.local(2026, 7, 1)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Boundary queries
|
|
57
|
+
plan.last_recurrence_time_before(Time.zone.now, dt_start_at: plan.created_at)
|
|
58
|
+
plan.next_recurrence_time_after(Time.zone.now, dt_start_at: plan.created_at)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
**Standalone** (no Rails):
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
gem 'recurable'
|
|
67
|
+
|
|
68
|
+
# Then in your code:
|
|
69
|
+
require 'recurable/recurrence'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**With Rails**:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
gem 'recurable'
|
|
76
|
+
|
|
77
|
+
# Then in your code:
|
|
78
|
+
require 'recurable'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires ActiveRecord >= 7.1 for `serialize` with `default:` keyword support.
|
|
82
|
+
|
|
83
|
+
## Recurrence Attributes
|
|
84
|
+
|
|
85
|
+
`Recurrence` is a pure Ruby data class with named attributes mapping to [RFC 5545 RRULE components](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10):
|
|
86
|
+
|
|
87
|
+
| Attribute | Type | RRULE Component | Example | Description |
|
|
88
|
+
|-----------|------|-----------------|---------|-------------|
|
|
89
|
+
| `frequency` | String | `FREQ` | `"MONTHLY"` | Every month |
|
|
90
|
+
| `interval` | Integer | `INTERVAL` | `3` | Every 3rd frequency period |
|
|
91
|
+
| `by_day` | Array\<String\> | `BYDAY` | `["+2MO"]` | The second Monday |
|
|
92
|
+
| `by_day` | | | `["MO", "WE", "FR"]` | Monday, Wednesday, and Friday |
|
|
93
|
+
| `by_month_day` | Array\<Integer\> | `BYMONTHDAY` | `[1, 15]` | The 1st and 15th of the month |
|
|
94
|
+
| `by_set_pos` | Array\<Integer\> | `BYSETPOS` | `[-1]` | The last occurrence in the set |
|
|
95
|
+
| `count` | Integer | `COUNT` | `10` | Stop after 10 occurrences |
|
|
96
|
+
| `repeat_until` | Time (UTC) | `UNTIL` | `Time.utc(2026, 12, 31)` | Stop after December 31, 2026 |
|
|
97
|
+
| `hour_of_day` | Array\<Integer\> | `BYHOUR` | `[9, 17]` | At 9 AM and 5 PM |
|
|
98
|
+
| `minute_of_hour` | Array\<Integer\> | `BYMINUTE` | `[0, 30]` | At :00 and :30 past the hour |
|
|
99
|
+
| `second_of_minute` | Array\<Integer\> | `BYSECOND` | `[0, 30]` | At :00 and :30 past the minute |
|
|
100
|
+
| `month_of_year` | Array\<Integer\> | `BYMONTH` | `[1, 6]` | In January and June |
|
|
101
|
+
| `day_of_year` | Array\<Integer\> | `BYYEARDAY` | `[1, -1]` | First and last day of the year |
|
|
102
|
+
| `week_of_year` | Array\<Integer\> | `BYWEEKNO` | `[1, 52]` | Weeks 1 and 52 |
|
|
103
|
+
| `week_start` | String | `WKST` | `"MO"` | Weeks start on Monday |
|
|
104
|
+
|
|
105
|
+
All array attributes accept scalars (auto-wrapped) or arrays. `nil` and `[]` are normalized to `nil`.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
recurrence = Recurrence.new
|
|
109
|
+
recurrence.by_day = 'MO' # => stored as ["MO"]
|
|
110
|
+
recurrence.by_day = %w[MO FR] # => stored as ["MO", "FR"]
|
|
111
|
+
recurrence.by_day = [] # => stored as nil
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Monthly Recurrence Options
|
|
115
|
+
|
|
116
|
+
Monthly recurrences support two modes, determined by which attributes are set:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
# By date: "the 15th of every month"
|
|
120
|
+
Recurrence.new(frequency: 'MONTHLY', interval: 1, by_month_day: [15])
|
|
121
|
+
.monthly_option # => "DATE"
|
|
122
|
+
|
|
123
|
+
# By nth weekday: "the last Friday of every month"
|
|
124
|
+
Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
|
|
125
|
+
.monthly_option # => "NTH_DAY"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### RRULE Generation & Parsing
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# Generate: attributes → RRULE string
|
|
132
|
+
recurrence = Recurrence.new(frequency: 'MONTHLY', interval: 1, by_day: ['FR'], by_set_pos: [-1])
|
|
133
|
+
recurrence.to_rrule # => "FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1"
|
|
134
|
+
|
|
135
|
+
# Parse: RRULE string → Recurrence
|
|
136
|
+
parsed = Recurrence.from_rrule('FREQ=MONTHLY;INTERVAL=1;BYDAY=FR;BYSETPOS=-1')
|
|
137
|
+
parsed.by_day # => ["FR"]
|
|
138
|
+
parsed.by_set_pos # => [-1]
|
|
139
|
+
|
|
140
|
+
# Round-trip
|
|
141
|
+
parsed.to_rrule == recurrence.to_rrule # => true
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### COUNT and UNTIL
|
|
145
|
+
|
|
146
|
+
`COUNT` and `UNTIL` are mutually exclusive per RFC 5545. The `Recurable` concern validates this:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# Limit by count
|
|
150
|
+
Recurrence.new(frequency: 'DAILY', interval: 1, count: 10)
|
|
151
|
+
.to_rrule # => "FREQ=DAILY;INTERVAL=1;COUNT=10"
|
|
152
|
+
|
|
153
|
+
# Limit by end date (stored as UTC)
|
|
154
|
+
Recurrence.new(frequency: 'DAILY', interval: 1, repeat_until: Time.utc(2026, 12, 31, 23, 59, 59))
|
|
155
|
+
.to_rrule # => "FREQ=DAILY;INTERVAL=1;UNTIL=20261231T235959Z"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Time Projection
|
|
159
|
+
|
|
160
|
+
`RruleUtils` is an includable module for DST-aware time projection. Any object with a `recurrence` method returning a `Recurrence` can include it. The `Recurable` concern includes it automatically.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
# Project occurrences in a date range
|
|
164
|
+
model.recurrence_times(
|
|
165
|
+
project_from: Time.zone.local(2026, 1, 1),
|
|
166
|
+
project_to: Time.zone.local(2026, 2, 1),
|
|
167
|
+
dt_start_at: model.created_at # optional; defaults to project_from
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Find the last occurrence before a boundary
|
|
171
|
+
model.last_recurrence_time_before(Time.zone.now, dt_start_at: model.created_at)
|
|
172
|
+
|
|
173
|
+
# Find the next occurrence after a boundary
|
|
174
|
+
model.next_recurrence_time_after(Time.zone.now, dt_start_at: model.created_at)
|
|
175
|
+
|
|
176
|
+
# Human-readable description
|
|
177
|
+
model.humanize_recurrence # => "every 3 months on the 15th"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Time projection delegates to the [rrule](https://github.com/square/ruby-rrule) gem with timezone-aware DST handling.
|
|
181
|
+
|
|
182
|
+
### DST Boundary Behavior
|
|
183
|
+
|
|
184
|
+
Daily and sub-daily recurrences behave differently across DST transitions.
|
|
185
|
+
|
|
186
|
+
**Spring forward** — On March 12, 2023 in `America/New_York`, clocks jump from 2:00 AM EST to 3:00 AM EDT.
|
|
187
|
+
|
|
188
|
+
A daily recurrence at 1:00 PM is unaffected — the wall-clock time stays consistent across the boundary:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
Mar 11 1:00 PM EST
|
|
192
|
+
Mar 12 1:00 PM EDT ← DST transition happened earlier this day, but 1 PM still fires
|
|
193
|
+
Mar 13 1:00 PM EDT
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
An hourly recurrence skips the non-existent 2:00 AM hour, producing 23 unique hours:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
12:00 AM EST
|
|
200
|
+
1:00 AM EST
|
|
201
|
+
3:00 AM EDT ← 2:00 AM doesn't exist, jumps straight to 3:00 AM
|
|
202
|
+
4:00 AM EDT
|
|
203
|
+
...
|
|
204
|
+
11:00 PM EDT
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Fall back** — On November 5, 2023, clocks fall back from 2:00 AM EDT to 1:00 AM EST. The 1:00 AM hour occurs twice, but the duplicate is removed:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
12:00 AM EDT
|
|
211
|
+
1:00 AM EDT
|
|
212
|
+
1:00 AM EST ← duplicate wall-clock hour, removed by .uniq
|
|
213
|
+
2:00 AM EST
|
|
214
|
+
3:00 AM EST
|
|
215
|
+
...
|
|
216
|
+
11:00 PM EST
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
This produces 24 unique wall-clock hours despite the repeated 1:00 AM.
|
|
220
|
+
|
|
221
|
+
Try it yourself:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
Time.use_zone('America/New_York') do
|
|
225
|
+
spring_forward = Time.zone.local(2023, 3, 12)
|
|
226
|
+
|
|
227
|
+
# Daily at 1 PM: fires every day regardless of DST
|
|
228
|
+
daily = Recurrence.new(frequency: 'DAILY', interval: 1)
|
|
229
|
+
model = Struct.new(:recurrence).new(daily).extend(RruleUtils)
|
|
230
|
+
model.recurrence_times(
|
|
231
|
+
project_from: Time.zone.local(2023, 3, 11, 13),
|
|
232
|
+
project_to: Time.zone.local(2023, 3, 13, 13),
|
|
233
|
+
dt_start_at: Time.zone.local(2023, 3, 11, 13)
|
|
234
|
+
).map { |t| t.strftime('%b %d %l:%M %p %Z') }
|
|
235
|
+
# => ["Mar 11 1:00 PM EST", "Mar 12 1:00 PM EDT", "Mar 13 1:00 PM EDT"]
|
|
236
|
+
|
|
237
|
+
# Hourly on spring-forward day: 23 unique hours, 2 AM is skipped
|
|
238
|
+
hourly = Recurrence.new(frequency: 'HOURLY', interval: 1)
|
|
239
|
+
model = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
|
|
240
|
+
hours = model.recurrence_times(
|
|
241
|
+
project_from: spring_forward.beginning_of_day,
|
|
242
|
+
project_to: spring_forward.end_of_day,
|
|
243
|
+
dt_start_at: spring_forward
|
|
244
|
+
)
|
|
245
|
+
hours.size # => 23
|
|
246
|
+
|
|
247
|
+
# Hourly on fall-back day: 24 unique hours, duplicate 1 AM removed
|
|
248
|
+
fall_back = Time.zone.local(2023, 11, 5)
|
|
249
|
+
model = Struct.new(:recurrence).new(hourly).extend(RruleUtils)
|
|
250
|
+
hours = model.recurrence_times(
|
|
251
|
+
project_from: fall_back.beginning_of_day,
|
|
252
|
+
project_to: fall_back.end_of_day,
|
|
253
|
+
dt_start_at: Time.zone.local(2023, 11, 1)
|
|
254
|
+
)
|
|
255
|
+
hours.size # => 24
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## The Recurable Concern
|
|
260
|
+
|
|
261
|
+
`Recurable` is an `ActiveSupport::Concern` included by ActiveRecord models with an `rrule` string column:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
class Plan < ApplicationRecord
|
|
265
|
+
include Recurable
|
|
266
|
+
end
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
It provides:
|
|
270
|
+
|
|
271
|
+
1. **Serialization** — `serialize :rrule, RecurrenceSerializer` transparently converts between DB strings and `Recurrence` objects
|
|
272
|
+
2. **Delegation** — all `Recurrence` attributes and frequency predicates are delegated to the `rrule` object
|
|
273
|
+
3. **Validation** — recurrence attributes are validated with appropriate constraints (frequency inclusion, interval positivity, array value ranges, BYDAY pattern matching, COUNT/UNTIL mutual exclusivity)
|
|
274
|
+
4. **Time projection** — includes `RruleUtils` for `recurrence_times`, `last_recurrence_time_before`, `next_recurrence_time_after`, and `humanize_recurrence`
|
|
275
|
+
|
|
276
|
+
## Supported Frequencies
|
|
277
|
+
|
|
278
|
+
| Frequency | Period | Example |
|
|
279
|
+
|-----------|--------|---------|
|
|
280
|
+
| `YEARLY` | ~365 days | `FREQ=YEARLY;INTERVAL=1;BYMONTH=1,6` |
|
|
281
|
+
| `MONTHLY` | ~31 days | `FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15` |
|
|
282
|
+
| `WEEKLY` | 7 days | `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR` |
|
|
283
|
+
| `DAILY` | 1 day | `FREQ=DAILY;INTERVAL=1` |
|
|
284
|
+
| `HOURLY` | 1 hour | `FREQ=HOURLY;INTERVAL=4;BYMINUTE=0,30` |
|
|
285
|
+
| `MINUTELY` | 1 minute | `FREQ=MINUTELY;INTERVAL=15` |
|
|
286
|
+
|
|
287
|
+
## Constants
|
|
288
|
+
|
|
289
|
+
`Recurrence` exposes named constants for use in validations and logic:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
Recurrence::DAILY # => "DAILY"
|
|
293
|
+
Recurrence::MONDAY # => "MO"
|
|
294
|
+
Recurrence::SUNDAY # => "SU"
|
|
295
|
+
Recurrence::MONTHLY_DATE # => "DATE"
|
|
296
|
+
Recurrence::MONTHLY_NTH_DAY # => "NTH_DAY"
|
|
297
|
+
Recurrence::FREQUENCIES # => {"YEARLY"=>365, "MONTHLY"=>31, ...}
|
|
298
|
+
Recurrence::DAYS_OF_WEEK # => ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
|
|
299
|
+
Recurrence::NTH_DAY_OF_MONTH # => {first: 1, second: 2, ..., last: -1}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Requirements
|
|
303
|
+
|
|
304
|
+
- Ruby >= 3.3
|
|
305
|
+
- ActiveModel >= 7.1
|
|
306
|
+
- ActiveSupport >= 7.1
|
|
307
|
+
- ActiveRecord >= 7.1 _(only if using the Recurable concern)_
|
|
308
|
+
|
|
309
|
+
## License
|
|
310
|
+
|
|
311
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_model'
|
|
4
|
+
|
|
5
|
+
class ArrayInclusionValidator < ActiveModel::EachValidator
|
|
6
|
+
def validate_each(record, attribute, value)
|
|
7
|
+
allowed = options[:in]
|
|
8
|
+
Array(value).each do |element|
|
|
9
|
+
valid = allowed.is_a?(Regexp) ? allowed.match?(element) : allowed.include?(element)
|
|
10
|
+
record.errors.add(attribute, "contains invalid value: #{element}") unless valid
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
# Core model representing an iCal RRULE recurrence pattern.
|
|
7
|
+
#
|
|
8
|
+
# Pure Ruby data class — no Rails dependencies. Handles RRULE string
|
|
9
|
+
# generation/parsing with named attributes and frequency comparison.
|
|
10
|
+
#
|
|
11
|
+
# recurrence = Recurrence.new(frequency: 'DAILY', interval: 1)
|
|
12
|
+
# recurrence.rrule # => "FREQ=DAILY;INTERVAL=1"
|
|
13
|
+
# recurrence.daily? # => true
|
|
14
|
+
#
|
|
15
|
+
class Recurrence
|
|
16
|
+
# Provides `<`, `<=`, `>`, `>=`, `==`, `between?`, and `clamp` by defining `<=>`.
|
|
17
|
+
include Comparable
|
|
18
|
+
|
|
19
|
+
def initialize(**attrs)
|
|
20
|
+
unknown = attrs.keys - ATTRIBUTES
|
|
21
|
+
raise ArgumentError, "Unknown attribute(s): #{unknown.join(', ')}" if unknown.any?
|
|
22
|
+
|
|
23
|
+
attrs.each { |attr, value| public_send(:"#{attr}=", value) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# iCal BYDAY codes derived from Date::DAYNAMES, ordered Sunday–Saturday.
|
|
27
|
+
# Exposes class constants: Recurrence::SUNDAY => 'SU', Recurrence::MONDAY => 'MO', etc.
|
|
28
|
+
# `const_set` returns the value of the constant and map returns an array of the transformed values.
|
|
29
|
+
DAYS_OF_WEEK = Date::DAYNAMES.map { |name| const_set(name.upcase, name[0, 2].upcase) }.freeze
|
|
30
|
+
# Ordered by increasing frequency. Values are approximate period in days.
|
|
31
|
+
# Order used by Comparable#<=> for RruleAdapter strategy selection.
|
|
32
|
+
# Exposes class constants: Recurrence::YEARLY, Recurrence::DAILY, etc. and defines frequency predicates.
|
|
33
|
+
FREQUENCIES = {
|
|
34
|
+
'YEARLY' => 365,
|
|
35
|
+
'MONTHLY' => 31,
|
|
36
|
+
'WEEKLY' => 7,
|
|
37
|
+
'DAILY' => 1,
|
|
38
|
+
'HOURLY' => 1 / 24.0,
|
|
39
|
+
'MINUTELY' => 1 / 24.0 / 60.0
|
|
40
|
+
}.each_key do |freq|
|
|
41
|
+
const_set(freq, freq)
|
|
42
|
+
define_method(:"#{freq.downcase}?") { freq == frequency }
|
|
43
|
+
end.freeze
|
|
44
|
+
FREQ_ORDER = FREQUENCIES.keys.each_with_index.to_h.freeze
|
|
45
|
+
|
|
46
|
+
# Exposes class constants: Recurrence::MONTHLY_DATE => 'DATE', Recurrence::MONTHLY_NTH_DAY => 'NTH_DAY'.
|
|
47
|
+
MONTHLY_OPTIONS = %w[DATE NTH_DAY].each { |opt| const_set("MONTHLY_#{opt}", opt) }.freeze
|
|
48
|
+
# Maps symbolic positions to iCal BYSETPOS integers. Positive 1–4 covers typical forward
|
|
49
|
+
# positions; negative -1/-2 covers "last" and "second to last" (deeper negatives are better
|
|
50
|
+
# expressed counting forward). A month has at most 5 of any single weekday.
|
|
51
|
+
NTH_DAY_OF_MONTH = {
|
|
52
|
+
first: 1,
|
|
53
|
+
second: 2,
|
|
54
|
+
third: 3,
|
|
55
|
+
fourth: 4,
|
|
56
|
+
last: -1,
|
|
57
|
+
second_to_last: -2
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# Positive = calendar date (1st–28th), negative = from end (-1 = last day, -2 = second to last).
|
|
61
|
+
# Capped at ±28 because February has 28 days in a common year.
|
|
62
|
+
DATE_OF_MONTH_RANGE = ((-28..-1).to_a + (1..28).to_a).freeze
|
|
63
|
+
HOUR_OF_DAY_RANGE = 0..23
|
|
64
|
+
MINUTE_OF_HOUR_RANGE = SECOND_OF_MINUTE_RANGE = 0..59
|
|
65
|
+
MONTH_OF_YEAR_RANGE = 1..12
|
|
66
|
+
DAY_OF_YEAR_RANGE = ((-366..-1).to_a + (1..366).to_a).freeze
|
|
67
|
+
WEEK_OF_YEAR_RANGE = ((-53..-1).to_a + (1..53).to_a).freeze
|
|
68
|
+
BYDAY_PATTERN = /\A[+-]?\d*(?:#{Regexp.union(DAYS_OF_WEEK).source})\z/
|
|
69
|
+
# Parses an RRULE UNTIL string (e.g. "20261231T235959Z") into a Time object.
|
|
70
|
+
UNTIL_PATTERN = /\A(?<Y>\d{4})(?<m>\d{2})(?<d>\d{2})T(?<H>\d{2})(?<M>\d{2})(?<S>\d{2})Z\z/
|
|
71
|
+
|
|
72
|
+
ATTRIBUTES = %i[
|
|
73
|
+
by_day by_month_day by_set_pos count day_of_year frequency hour_of_day interval
|
|
74
|
+
minute_of_hour month_of_year repeat_until
|
|
75
|
+
second_of_minute week_of_year week_start
|
|
76
|
+
].freeze
|
|
77
|
+
|
|
78
|
+
ARRAY_ATTRIBUTES = %i[
|
|
79
|
+
by_day by_month_day by_set_pos day_of_year hour_of_day minute_of_hour
|
|
80
|
+
month_of_year second_of_minute week_of_year
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
attr_accessor(*(ATTRIBUTES - ARRAY_ATTRIBUTES - %i[repeat_until]))
|
|
84
|
+
attr_reader :repeat_until, *ARRAY_ATTRIBUTES
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
def from_rrule(rrule)
|
|
88
|
+
new(**attributes_from(parse_components(rrule)))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Parses "FREQ=DAILY;INTERVAL=1;BYDAY=MO…" into {"FREQ"=>"DAILY", "BYDAY"=>"MO", …}
|
|
94
|
+
def parse_components(rrule)
|
|
95
|
+
rrule.split(';').each_with_object({}) do |pair, hash|
|
|
96
|
+
next if pair.strip.empty?
|
|
97
|
+
|
|
98
|
+
key, value = pair.split('=', 2)
|
|
99
|
+
hash[key] = value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def attributes_from(components)
|
|
104
|
+
{
|
|
105
|
+
by_day: split_list(components['BYDAY']),
|
|
106
|
+
by_month_day: split_int_list(components['BYMONTHDAY']),
|
|
107
|
+
by_set_pos: split_int_list(components['BYSETPOS']),
|
|
108
|
+
count: components['COUNT']&.to_i,
|
|
109
|
+
day_of_year: split_int_list(components['BYYEARDAY']),
|
|
110
|
+
frequency: components['FREQ'],
|
|
111
|
+
hour_of_day: split_int_list(components['BYHOUR']),
|
|
112
|
+
interval: components['INTERVAL']&.to_i || 1,
|
|
113
|
+
minute_of_hour: split_int_list(components['BYMINUTE']),
|
|
114
|
+
month_of_year: split_int_list(components['BYMONTH']),
|
|
115
|
+
repeat_until: components['UNTIL'],
|
|
116
|
+
second_of_minute: split_int_list(components['BYSECOND']),
|
|
117
|
+
week_of_year: split_int_list(components['BYWEEKNO']),
|
|
118
|
+
week_start: components['WKST']
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def split_list(csv)
|
|
123
|
+
return unless csv
|
|
124
|
+
|
|
125
|
+
list = csv.split(',')
|
|
126
|
+
list unless list.empty?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def split_int_list(csv)
|
|
130
|
+
split_list(csv)&.map(&:to_i)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
ARRAY_ATTRIBUTES.each do |attr|
|
|
135
|
+
define_method(:"#{attr}=") do |value|
|
|
136
|
+
coerced = Array(value)
|
|
137
|
+
instance_variable_set(:"@#{attr}", coerced.empty? ? nil : coerced)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def repeat_until=(value)
|
|
142
|
+
@repeat_until = case value
|
|
143
|
+
when nil, '' then nil
|
|
144
|
+
when Time then value.utc
|
|
145
|
+
when String then parse_until(value)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def to_rrule
|
|
150
|
+
{
|
|
151
|
+
'FREQ' => frequency,
|
|
152
|
+
'INTERVAL' => interval,
|
|
153
|
+
'COUNT' => non_blank(count),
|
|
154
|
+
'UNTIL' => format_until(repeat_until),
|
|
155
|
+
'BYDAY' => join_list(by_day),
|
|
156
|
+
'BYMONTHDAY' => join_list(by_month_day),
|
|
157
|
+
'BYMONTH' => join_list(month_of_year),
|
|
158
|
+
'BYHOUR' => join_list(hour_of_day),
|
|
159
|
+
'BYMINUTE' => join_list(minute_of_hour),
|
|
160
|
+
'BYSECOND' => join_list(second_of_minute),
|
|
161
|
+
'BYYEARDAY' => join_list(day_of_year),
|
|
162
|
+
'BYWEEKNO' => join_list(week_of_year),
|
|
163
|
+
'BYSETPOS' => join_list(by_set_pos),
|
|
164
|
+
'WKST' => non_blank(week_start)
|
|
165
|
+
}.filter_map { |k, v| "#{k}=#{v}" unless v.nil? }.join(';')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monthly_option
|
|
169
|
+
return unless frequency == 'MONTHLY'
|
|
170
|
+
return 'NTH_DAY' if by_set_pos&.any? && by_day&.any?
|
|
171
|
+
|
|
172
|
+
'DATE' if by_month_day&.any?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def by_month_day_option? = monthly_option == 'DATE'
|
|
176
|
+
def by_set_pos_option? = monthly_option == 'NTH_DAY'
|
|
177
|
+
|
|
178
|
+
def <=>(other)
|
|
179
|
+
return super unless other.is_a?(self.class)
|
|
180
|
+
|
|
181
|
+
FREQ_ORDER[frequency] <=> FREQ_ORDER[other.frequency]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def non_blank(value)
|
|
187
|
+
value unless value.nil? || value.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def join_list(array)
|
|
191
|
+
non_blank(array&.join(','))
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def format_until(time)
|
|
195
|
+
time&.utc&.strftime('%Y%m%dT%H%M%SZ')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def parse_until(value)
|
|
199
|
+
return unless (match = non_blank(value)&.match(UNTIL_PATTERN))
|
|
200
|
+
|
|
201
|
+
Time.utc(match[:Y], match[:m], match[:d], match[:H], match[:M], match[:S])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'recurrence'
|
|
4
|
+
|
|
5
|
+
# Serializes between RRULE strings and Recurrence objects. A Recurrence fully
|
|
6
|
+
# represents an RRULE — it can be constructed from the string and stored back as one.
|
|
7
|
+
class RecurrenceSerializer
|
|
8
|
+
def self.load(rrule)
|
|
9
|
+
Recurrence.from_rrule(rrule) if rrule.present?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.dump(recurrence_instance) = recurrence_instance&.to_rrule
|
|
13
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/core_ext/numeric/time'
|
|
4
|
+
require 'active_support/core_ext/time/zones'
|
|
5
|
+
require 'rrule'
|
|
6
|
+
|
|
7
|
+
module RruleUtils
|
|
8
|
+
SUB_DAILY_NOUNS = { 'HOURLY' => 'hour', 'MINUTELY' => 'minute' }.freeze
|
|
9
|
+
|
|
10
|
+
def recurrence_times(project_from:, project_to:, dt_start_at: nil)
|
|
11
|
+
dt_start_at ||= project_from
|
|
12
|
+
RRule::Rule.new(recurrence.to_rrule, dtstart: dt_start_at, tzid: Time.zone.tzinfo.identifier)
|
|
13
|
+
.between(project_from, project_to)
|
|
14
|
+
.uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def last_recurrence_time_before(before, dt_start_at:)
|
|
18
|
+
project_from = (Recurrence::FREQUENCIES[recurrence.frequency] * recurrence.interval).days.ago(before)
|
|
19
|
+
recurrence_times(project_from:, project_to: before, dt_start_at:).last
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def next_recurrence_time_after(after, dt_start_at:)
|
|
23
|
+
project_to = (Recurrence::FREQUENCIES[recurrence.frequency] * recurrence.interval).days.since(after)
|
|
24
|
+
recurrence_times(project_from: after, project_to:, dt_start_at:).first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def humanize_recurrence
|
|
28
|
+
noun = SUB_DAILY_NOUNS[recurrence.frequency]
|
|
29
|
+
return RRule::Rule.new(recurrence.to_rrule).humanize unless noun
|
|
30
|
+
|
|
31
|
+
"every #{recurrence.interval == 1 ? noun : "#{recurrence.interval} #{noun}s"}"
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/recurable.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
require_relative 'recurable/version'
|
|
6
|
+
require_relative 'recurable/recurrence'
|
|
7
|
+
require_relative 'recurable/rrule_utils'
|
|
8
|
+
require_relative 'recurable/array_inclusion_validator'
|
|
9
|
+
require_relative 'recurable/recurrence_serializer'
|
|
10
|
+
# A concern to be included by ActiveRecord models that persist an rrule string to the database. This concern gives them
|
|
11
|
+
# access to a Recurrence object (instead of an rrule string) which encapsulates user-friendly getters and
|
|
12
|
+
# setters for the rrule, as well as validation, display, and projection concerns.
|
|
13
|
+
module Recurable
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
included do
|
|
17
|
+
include RruleUtils
|
|
18
|
+
|
|
19
|
+
# This concern should only be used with a model that has an `rrule` string column.
|
|
20
|
+
serialize :rrule, coder: RecurrenceSerializer, default: Recurrence.new(frequency: 'DAILY', interval: 1)
|
|
21
|
+
alias_attribute :recurrence, :rrule
|
|
22
|
+
|
|
23
|
+
delegate(*Recurrence::ATTRIBUTES.flat_map { |attr| [attr, :"#{attr}="] },
|
|
24
|
+
*Recurrence::FREQUENCIES.each_key.map { |freq| :"#{freq.downcase}?" },
|
|
25
|
+
:by_month_day_option?, :by_set_pos_option?,
|
|
26
|
+
to: :rrule)
|
|
27
|
+
|
|
28
|
+
validates :by_day, array_inclusion: { in: Recurrence::BYDAY_PATTERN }, allow_blank: true
|
|
29
|
+
validates :by_month_day, array_inclusion: { in: Recurrence::DATE_OF_MONTH_RANGE }, if: :by_month_day_option?
|
|
30
|
+
validates :by_set_pos, array_inclusion: { in: Recurrence::NTH_DAY_OF_MONTH.values }, if: :by_set_pos_option?
|
|
31
|
+
validates :count, numericality: { only_integer: true, greater_than: 0 }, allow_blank: true
|
|
32
|
+
validates :day_of_year, array_inclusion: { in: Recurrence::DAY_OF_YEAR_RANGE }, allow_blank: true
|
|
33
|
+
validates :frequency, presence: true, inclusion: { in: Recurrence::FREQUENCIES.keys }
|
|
34
|
+
validates :hour_of_day, array_inclusion: { in: Recurrence::HOUR_OF_DAY_RANGE }, allow_blank: true
|
|
35
|
+
validates :interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
36
|
+
validates :minute_of_hour, array_inclusion: { in: Recurrence::MINUTE_OF_HOUR_RANGE }, allow_blank: true
|
|
37
|
+
validates :month_of_year, array_inclusion: { in: Recurrence::MONTH_OF_YEAR_RANGE }, allow_blank: true
|
|
38
|
+
validates :second_of_minute, array_inclusion: { in: Recurrence::SECOND_OF_MINUTE_RANGE }, allow_blank: true
|
|
39
|
+
validates :week_of_year, array_inclusion: { in: Recurrence::WEEK_OF_YEAR_RANGE }, allow_blank: true
|
|
40
|
+
validates :week_start, inclusion: { in: Recurrence::DAYS_OF_WEEK }, allow_blank: true
|
|
41
|
+
validate :count_and_until_mutually_exclusive
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def count_and_until_mutually_exclusive
|
|
47
|
+
errors.add(:base, 'COUNT and UNTIL are mutually exclusive') if count.present? && repeat_until.present?
|
|
48
|
+
end
|
|
49
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: recurable
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Aaron Rosenberg
|
|
8
|
+
- Matt Lewis
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activemodel
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: activesupport
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '7'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: rrule
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0.5'
|
|
60
|
+
type: :runtime
|
|
61
|
+
prerelease: false
|
|
62
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0.5'
|
|
67
|
+
description: |
|
|
68
|
+
Provides Recurrence, RruleUtils, and RecurrenceSerializer as a standalone library
|
|
69
|
+
for working with iCal RRULE recurrence patterns (full RFC 5545 support). Optionally
|
|
70
|
+
integrates with Rails via the Recurable concern for transparent ActiveRecord
|
|
71
|
+
serialization. Handles DST-safe projection for all frequencies from minutely through yearly.
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- LICENSE
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/recurable.rb
|
|
79
|
+
- lib/recurable/array_inclusion_validator.rb
|
|
80
|
+
- lib/recurable/recurrence.rb
|
|
81
|
+
- lib/recurable/recurrence_serializer.rb
|
|
82
|
+
- lib/recurable/rrule_utils.rb
|
|
83
|
+
- lib/recurable/version.rb
|
|
84
|
+
homepage: https://github.com/agrberg/recurable
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
rubygems_mfa_required: 'true'
|
|
89
|
+
source_code_uri: https://github.com/agrberg/recurable
|
|
90
|
+
bug_tracker_uri: https://github.com/agrberg/recurable/issues
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '3.3'
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 3.6.9
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: iCal RRULE recurrence library with optional Rails integration
|
|
108
|
+
test_files: []
|