Skip to content

Commit ce99543

Browse files
nehresmaNicolas Marlierpacso
authored
Update to BYSETPOS support (#449)
* Support BYSETPOS for MONTHLY AND YEARLY freq * Modernize BYSETPOS commit A few small updates to Nicolas Marlier's BYSETPOS support added in PR #349 * address the spec DST sensitivity in .to_yaml round trips * update PR from feedback rebased against master -- its been 4 years * excluding until, not util * remove no longer needed TimeUtil active_support require This was a holdover from the original PR back in 2016. TimeUtil has since been refactored to not need this, but the require was inadvertently left. * fix interval use with bysetpos * remove unneeded use of activesupport for date arithmetic * support for bysetpos with freq=weekly * support for parsing rrules from ical that are very long and wrap * dont require the wrapped line to be the last the ical string * nitpick fixes - use map and double quotes for consistency * do not rely on ActiveSupport-only helper methods * fix BYSETPOS serialization * fix SETBYPOS with non BYDAY expansions * expand BYSETPOS spec coverage - weekly: positive/multi/mixed positions, BYHOUR expansion - monthly: negative positions, BYMONTHDAY, BYMINUTE - yearly: multiple positive/negative positions * adding BYSETPOS validations for daily/hourly/minutely * add verification that bysetpos runs after other byXXX filters * BYSETPOS interval boundaries specs * dd BYSETPOS ordering specs * add BYSETPOS anchor and interval specs * refactor and create BYSETPOS helper for interval bounds * adding more comprehensive BYSETPOS specs This covers repeated values, out-of-range, and UNTIL cases * updating readme with bysetpos support * adding BYSETPOS to_ical spec coverage * adding SECONDLY BYSETPOS support and specs * adding BYYEARDAY BYSETPOS specs * Gemfile adjustments for Ruby stdlib changes * adding support for more versions of ActiveSupport * Fix BYSETPOS to count positions from interval start per RFC 5545 BYSETPOS was anchoring candidate enumeration to DTSTART instead of the interval boundary, causing positions to be miscounted. This skipped the first occurrence when DTSTART itself matched a BYSETPOS position (e.g., starting on the 2nd Tuesday when selecting BYSETPOS=2). The fix anchors the temporary schedule to the interval start based on which BYxxx components expand the candidate set (day, month, hour, etc.). * Fix flaky YAML round-trip test that failed across DST boundaries Use UTC instead of Time.now to avoid DST offset mismatches when YAML serialization loses timezone info and preserves only the numeric offset. * linting fixes * CHANGELOG entry * Fix lint errors * Add missing round-trip YAML tests for BYSETPOS * Create time objects correctly * Add edge cases for BYSETPOS --------- Co-authored-by: Nicolas Marlier <[email protected]> Co-authored-by: Jon Pascoe <[email protected]>
1 parent 83144a5 commit ce99543

32 files changed

+1968
-26
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [UNRELEASED]
8+
### Added
9+
- Support for BYSETPOS with all rule frequencies (yearly, monthly, weekly, daily, hourly, minutely, secondly). ([#449](https://github.com/ice-cube-ruby/ice_cube/pull/449)) by [@nehresma](https://github.com/nehresma) and [@NicolasMarlier](https://github.com/NicolasMarlier)
10+
811
### Changed
912
- Updated CI test matrix to support Rails 7.2, 8.0, 8.1 and Ruby 3.2, 3.3, 3.4, 4.0 by [@nehresma](https://github.com/nehresma) and [@pacso](https://github.com/pacso)
1013

11-
1214
## [0.17.0] - 2024-07-18
1315
### Added
1416
- Indonesian translations. ([#505](https://github.com/seejohnrun/ice_cube/pull/505)) by [@achmiral](https://github.com/achmiral)

Gemfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,10 @@ compatible_rails_versions = [
99
gem "activesupport", (ENV["RAILS_VERSION"] || compatible_rails_versions), require: false
1010
gem "i18n", require: false
1111
gem "tzinfo", require: false # only needed explicitly for RAILS_VERSION=3
12+
13+
gem "base64", require: false # remove base64 deprecation warnings for Ruby 3.3+
14+
gem "bigdecimal", require: false # remove bigdecimal deprecation warnings for Ruby 3.3+
15+
gem "mutex_m", require: false # ActiveSupport dependency on Ruby 3.4+
16+
gem "ostruct", require: false # remove ostruct deprecation warnings for Ruby 3.4+
17+
gem "logger", require: false # remove logger deprecation warnings for Ruby 3.4+
18+
gem "benchmark", require: false # remove benchmark deprecation warnings for Ruby 3.4+

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,31 @@ schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(:march)
253253
schedule.add_recurrence_rule IceCube::Rule.yearly(3).month_of_year(3)
254254
```
255255

256+
### BYSETPOS (select the nth occurrence)
257+
258+
BYSETPOS selects the nth occurrence within each interval after all other BYxxx
259+
filters/expansions are applied. Use positive values (from the start) or
260+
negative values (from the end). Repeated values do not duplicate occurrences,
261+
and positions beyond the set size yield no occurrence for that interval.
262+
RFC 5545 requires BYSETPOS to be used with another BYxxx rule part; IceCube
263+
allows BYSETPOS without another BYxxx and applies it to the single occurrence
264+
in each interval.
265+
266+
```ruby
267+
# last weekday of the month
268+
schedule.add_recurrence_rule(
269+
IceCube::Rule.monthly.day(:monday, :tuesday, :wednesday, :thursday, :friday).by_set_pos(-1)
270+
)
271+
272+
# second occurrence in each day's expanded set
273+
schedule.add_recurrence_rule(
274+
IceCube::Rule.daily.hour_of_day(9, 17).by_set_pos(2)
275+
)
276+
```
277+
278+
Note: If you expand with BYHOUR/BYMINUTE/BYSECOND, any unspecified smaller
279+
time components are inherited from the schedule's start_time.
280+
256281
### Hourly (by hour of day)
257282

258283
```ruby

lib/ice_cube.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ module Validations
5050
autoload :YearlyInterval, "ice_cube/validations/yearly_interval"
5151
autoload :HourlyInterval, "ice_cube/validations/hourly_interval"
5252

53+
autoload :BySetPosHelper, "ice_cube/validations/by_set_pos_helper"
54+
autoload :SecondlyBySetPos, "ice_cube/validations/secondly_by_set_pos"
55+
autoload :MinutelyBySetPos, "ice_cube/validations/minutely_by_set_pos"
56+
autoload :HourlyBySetPos, "ice_cube/validations/hourly_by_set_pos"
57+
autoload :DailyBySetPos, "ice_cube/validations/daily_by_set_pos"
58+
autoload :WeeklyBySetPos, "ice_cube/validations/weekly_by_set_pos"
59+
autoload :MonthlyBySetPos, "ice_cube/validations/monthly_by_set_pos"
60+
autoload :YearlyBySetPos, "ice_cube/validations/yearly_by_set_pos"
61+
5362
autoload :HourOfDay, "ice_cube/validations/hour_of_day"
5463
autoload :MonthOfYear, "ice_cube/validations/month_of_year"
5564
autoload :MinuteOfHour, "ice_cube/validations/minute_of_hour"

lib/ice_cube/occurrence.rb

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,8 @@ def to_time
8585
# time formats and is only used when ActiveSupport is available.
8686
#
8787
def to_s(format = nil)
88-
if format && to_time.public_method(:to_s).arity != 0
89-
t0, t1 = start_time.to_s(format), end_time.to_s(format)
90-
else
91-
t0, t1 = start_time.to_s, end_time.to_s
92-
end
88+
t0 = format_time(start_time, format)
89+
t1 = format_time(end_time, format)
9390
(duration > 0) ? "#{t0} - #{t1}" : t0
9491
end
9592

@@ -98,5 +95,18 @@ def overnight?
9895
midnight = Time.new(offset.year, offset.month, offset.day)
9996
midnight < end_time
10097
end
98+
99+
private
100+
101+
# Normalize formatted output across ActiveSupport versions:
102+
# Rails 7.1+ prefers to_fs, older versions use to_formatted_s or to_s(:format).
103+
def format_time(time, format)
104+
return time.to_s unless format
105+
return time.to_fs(format) if time.respond_to?(:to_fs)
106+
return time.to_formatted_s(format) if time.respond_to?(:to_formatted_s)
107+
return time.to_s(format) if time.public_method(:to_s).arity != 0
108+
109+
time.to_s
110+
end
101111
end
102112
end

lib/ice_cube/parsers/ical_parser.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@ module IceCube
22
class IcalParser
33
def self.schedule_from_ical(ical_string, options = {})
44
data = {}
5+
6+
# First join lines that are wrapped
7+
lines = []
58
ical_string.each_line do |line|
9+
if lines[-1] && line =~ /\A[ \t].+/
10+
lines[-1] = lines[-1].strip + line.sub(/\A[ \t]+/, "")
11+
else
12+
lines << line
13+
end
14+
end
15+
16+
lines.each do |line|
617
(property, value) = line.split(":")
718
(property, _tzid) = property.split(";")
819
case property
@@ -75,7 +86,7 @@ def self.rule_from_ical(ical)
7586
when "BYYEARDAY"
7687
validations[:day_of_year] = value.split(",").map(&:to_i)
7788
when "BYSETPOS"
78-
# noop
89+
validations[:by_set_pos] = value.split(",").map(&:to_i)
7990
else
8091
validations[name] = nil # invalid type
8192
end

lib/ice_cube/rule.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,11 @@ def to_hash
4949
raise MethodNotImplemented, "Expected to be overridden by subclasses"
5050
end
5151

52-
def next_time(time, schedule, closing_time)
52+
def next_time(time, schedule, closing_time, increment: true)
5353
end
5454

5555
def on?(time, schedule)
56-
next_time(time, schedule, time).to_i == time.to_i
56+
next_time(time, schedule, time, increment: false).to_i == time.to_i
5757
end
5858

5959
class << self

lib/ice_cube/rules/daily_rule.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class DailyRule < ValidatedRule
1010
# include Validations::DayOfYear # n/a
1111

1212
include Validations::DailyInterval
13+
include Validations::DailyBySetPos
1314

1415
def initialize(interval = 1)
1516
super

lib/ice_cube/rules/hourly_rule.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class HourlyRule < ValidatedRule
1010
include Validations::DayOfYear
1111

1212
include Validations::HourlyInterval
13+
include Validations::HourlyBySetPos
1314

1415
def initialize(interval = 1)
1516
super

lib/ice_cube/rules/minutely_rule.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class MinutelyRule < ValidatedRule
1010
include Validations::DayOfYear
1111

1212
include Validations::MinutelyInterval
13+
include Validations::MinutelyBySetPos
1314

1415
def initialize(interval = 1)
1516
super

0 commit comments

Comments
 (0)