ice_cube 0.6.14 → 0.7.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.
- data/lib/ice_cube.rb +63 -37
- data/lib/ice_cube/builders/hash_builder.rb +27 -0
- data/lib/ice_cube/builders/ical_builder.rb +59 -0
- data/lib/ice_cube/builders/string_builder.rb +74 -0
- data/lib/ice_cube/errors/count_exceeded.rb +7 -0
- data/lib/ice_cube/errors/until_exceeded.rb +7 -0
- data/lib/ice_cube/rule.rb +85 -147
- data/lib/ice_cube/rules/daily_rule.rb +5 -27
- data/lib/ice_cube/rules/hourly_rule.rb +6 -26
- data/lib/ice_cube/rules/minutely_rule.rb +5 -25
- data/lib/ice_cube/rules/monthly_rule.rb +6 -30
- data/lib/ice_cube/rules/secondly_rule.rb +5 -26
- data/lib/ice_cube/rules/weekly_rule.rb +5 -36
- data/lib/ice_cube/rules/yearly_rule.rb +8 -34
- data/lib/ice_cube/schedule.rb +257 -229
- data/lib/ice_cube/single_occurrence_rule.rb +28 -0
- data/lib/ice_cube/time_util.rb +202 -76
- data/lib/ice_cube/validated_rule.rb +107 -0
- data/lib/ice_cube/validations/count.rb +56 -0
- data/lib/ice_cube/validations/daily_interval.rb +51 -0
- data/lib/ice_cube/validations/day.rb +45 -31
- data/lib/ice_cube/validations/day_of_month.rb +44 -44
- data/lib/ice_cube/validations/day_of_week.rb +60 -47
- data/lib/ice_cube/validations/day_of_year.rb +48 -44
- data/lib/ice_cube/validations/hour_of_day.rb +42 -30
- data/lib/ice_cube/validations/hourly_interval.rb +50 -0
- data/lib/ice_cube/validations/lock.rb +47 -0
- data/lib/ice_cube/validations/minute_of_hour.rb +42 -31
- data/lib/ice_cube/validations/minutely_interval.rb +50 -0
- data/lib/ice_cube/validations/month_of_year.rb +39 -30
- data/lib/ice_cube/validations/monthly_interval.rb +47 -0
- data/lib/ice_cube/validations/schedule_lock.rb +41 -0
- data/lib/ice_cube/validations/second_of_minute.rb +39 -30
- data/lib/ice_cube/validations/secondly_interval.rb +50 -0
- data/lib/ice_cube/validations/until.rb +49 -0
- data/lib/ice_cube/validations/weekly_interval.rb +50 -0
- data/lib/ice_cube/validations/yearly_interval.rb +45 -0
- data/lib/ice_cube/version.rb +2 -2
- data/spec/spec_helper.rb +13 -0
- metadata +50 -9
- data/lib/ice_cube/rule_occurrence.rb +0 -94
- data/lib/ice_cube/validation.rb +0 -44
- data/lib/ice_cube/validation_types.rb +0 -137
@@ -0,0 +1,28 @@
|
|
1
|
+
module IceCube
|
2
|
+
|
3
|
+
class SingleOccurrenceRule < Rule
|
4
|
+
|
5
|
+
attr_reader :time
|
6
|
+
|
7
|
+
def initialize(time)
|
8
|
+
@time = time
|
9
|
+
end
|
10
|
+
|
11
|
+
# Always terminating
|
12
|
+
def terminating?
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
def next_time(t, schedule, closing_time)
|
17
|
+
unless closing_time && closing_time < t
|
18
|
+
time if time >= t
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_hash
|
23
|
+
{ :time => time }
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
data/lib/ice_cube/time_util.rb
CHANGED
@@ -1,101 +1,227 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
1
3
|
module IceCube
|
2
4
|
|
3
5
|
module TimeUtil
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
6
|
+
|
7
|
+
LEAP_YEAR_MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
8
|
+
COMMON_YEAR_MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
9
|
+
|
10
|
+
DAYS = {
|
11
|
+
:sunday => 0, :monday => 1, :tuesday => 2, :wednesday => 3,
|
12
|
+
:thursday => 4, :friday => 5, :saturday => 6
|
13
|
+
}
|
14
|
+
|
15
|
+
MONTHS = {
|
16
|
+
:january => 1, :february => 2, :march => 3, :april => 4, :may => 5,
|
17
|
+
:june => 6, :july => 7, :august => 8, :september => 9, :october => 10,
|
18
|
+
:november => 11, :december => 12
|
19
|
+
}
|
20
|
+
|
21
|
+
# Serialize a time appropriate for storing
|
8
22
|
def self.serialize_time(time)
|
9
|
-
if time.is_a?(ActiveSupport::TimeWithZone)
|
10
|
-
{ :time => time, :zone => time.time_zone.name }
|
23
|
+
if defined?(:ActiveSupport) && const_defined?(:ActiveSupport) && time.is_a?(ActiveSupport::TimeWithZone)
|
24
|
+
{ :time => time.utc, :zone => time.time_zone.name }
|
11
25
|
elsif time.is_a?(Time)
|
12
26
|
time
|
13
27
|
end
|
14
28
|
end
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
if time_or_hash.is_a?(
|
29
|
+
|
30
|
+
# Deserialize a time serialized with serialize_time
|
31
|
+
def self.deserialize_time(time_or_hash)
|
32
|
+
if time_or_hash.is_a?(Time)
|
33
|
+
time_or_hash
|
34
|
+
elsif time_or_hash.is_a?(Hash)
|
19
35
|
time_or_hash[:time].in_time_zone(time_or_hash[:zone])
|
20
36
|
end
|
21
37
|
end
|
22
38
|
|
23
|
-
#
|
24
|
-
def self.
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
month_distance.times do
|
30
|
-
|
31
|
-
prev_mark = next_mark
|
32
|
-
next_mark += days_in_month_of_next_mark * IceCube::ONE_DAY
|
33
|
-
|
34
|
-
# only moving one day at a time, so this suffices
|
35
|
-
months_covered = next_mark.month - prev_mark.month
|
36
|
-
months_covered += 12 if months_covered < 0
|
37
|
-
|
38
|
-
# step back to the end of the previous month of months_covered went too far
|
39
|
-
if months_covered == 2
|
40
|
-
next_mark -= next_mark.mday * IceCube::ONE_DAY
|
41
|
-
end
|
42
|
-
|
43
|
-
days_in_month_of_next_mark = days_in_month(next_mark)
|
44
|
-
next_mark = adjust(next_mark, prev_mark)
|
45
|
-
|
46
|
-
end
|
47
|
-
|
48
|
-
# at the end, there's a chance we're not on the correct day,
|
49
|
-
# but if we're not - we will always be behind it in the correct month
|
50
|
-
# if there exists no proper day in the month for us, return nil - otherwise, return that date
|
51
|
-
|
52
|
-
if days_in_month_of_next_mark >= date.mday
|
53
|
-
next_mark += (date.mday - next_mark.mday) * IceCube::ONE_DAY
|
54
|
-
end
|
55
|
-
|
39
|
+
# Get the beginning of a date
|
40
|
+
def self.beginning_of_date(date)
|
41
|
+
date.respond_to?(:beginning_of_day) ?
|
42
|
+
date.beginning_of_day :
|
43
|
+
Time.local(date.year, date.month, date.day, 0, 0, 0)
|
56
44
|
end
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
45
|
+
|
46
|
+
# Get the end of a date
|
47
|
+
def self.end_of_date(date)
|
48
|
+
date.respond_to?(:end_of_day) ?
|
49
|
+
date.end_of_day :
|
50
|
+
Time.local(date.year, date.month, date.day, 23, 59, 59)
|
61
51
|
end
|
62
|
-
|
63
|
-
|
64
|
-
|
52
|
+
|
53
|
+
# Convert a symbol to a numeric month
|
54
|
+
def self.symbol_to_month(sym)
|
55
|
+
month = MONTHS[sym]
|
56
|
+
raise "No such month: #{sym}" unless month
|
57
|
+
month
|
65
58
|
end
|
66
|
-
|
67
|
-
|
68
|
-
|
59
|
+
|
60
|
+
# Convert a symbol to a numeric day
|
61
|
+
def self.symbol_to_day(sym)
|
62
|
+
day = DAYS[sym]
|
63
|
+
raise "No such day: #{sym}" unless day
|
64
|
+
day
|
69
65
|
end
|
70
|
-
|
71
|
-
|
72
|
-
|
66
|
+
|
67
|
+
# Return the count of the number of times wday appears in the month,
|
68
|
+
# and which of those time falls on
|
69
|
+
def self.which_occurrence_in_month(time, wday)
|
70
|
+
first_occurrence = ((7 - Time.utc(time.year, time.month, 1).wday) + time.wday) % 7 + 1
|
71
|
+
this_weekday_in_month_count = ((days_in_month(time) - first_occurrence + 1) / 7.0).ceil
|
72
|
+
nth_occurrence_of_weekday = (time.mday - first_occurrence) / 7 + 1
|
73
|
+
[nth_occurrence_of_weekday, this_weekday_in_month_count]
|
73
74
|
end
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
75
|
+
|
76
|
+
# Get the days in the month for +time
|
77
|
+
def self.days_in_month(time)
|
78
|
+
days_in_month_year(time.month, time.year)
|
78
79
|
end
|
79
|
-
|
80
|
-
def self.
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
80
|
+
|
81
|
+
def self.days_in_next_month(time)
|
82
|
+
# Get the next month
|
83
|
+
year = time.year
|
84
|
+
month = time.month + 1
|
85
|
+
if month > 12
|
86
|
+
month %= 12
|
87
|
+
year += 1
|
86
88
|
end
|
89
|
+
# And then determine
|
90
|
+
days_in_month_year(month, year)
|
87
91
|
end
|
88
|
-
|
89
|
-
def self.
|
90
|
-
|
91
|
-
minutes = duration / 60; duration %= 60
|
92
|
-
repr = ''
|
93
|
-
repr << "#{hours}H" if hours > 0
|
94
|
-
repr << "#{minutes}M" if minutes > 0
|
95
|
-
repr << "#{duration}S" if duration > 0
|
96
|
-
"PT#{repr}"
|
92
|
+
|
93
|
+
def self.days_in_month_year(month, year)
|
94
|
+
is_leap?(year) ? LEAP_YEAR_MONTH_DAYS[month - 1] : COMMON_YEAR_MONTH_DAYS[month - 1]
|
97
95
|
end
|
98
96
|
|
97
|
+
# Number of days in a year
|
98
|
+
def self.days_in_year(time)
|
99
|
+
is_leap?(time.year) ? 366 : 365
|
100
|
+
end
|
101
|
+
|
102
|
+
# Number of days to n years
|
103
|
+
def self.days_in_n_years(time, year_distance)
|
104
|
+
sum = 0
|
105
|
+
wrapper = TimeWrapper.new(time)
|
106
|
+
year_distance.times do
|
107
|
+
diy = days_in_year(wrapper.to_time)
|
108
|
+
sum += diy
|
109
|
+
wrapper.add(:day, diy)
|
110
|
+
end
|
111
|
+
sum
|
112
|
+
end
|
113
|
+
|
114
|
+
# The number of days in n months
|
115
|
+
def self.days_in_n_months(time, month_distance)
|
116
|
+
# move to a safe spot in the month to make this computation
|
117
|
+
desired_day = time.day
|
118
|
+
time -= IceCube::ONE_DAY * (time.day - 27) if time.day >= 28
|
119
|
+
# move n months ahead
|
120
|
+
sum = 0
|
121
|
+
wrapper = TimeWrapper.new(time)
|
122
|
+
month_distance.times do
|
123
|
+
dim = days_in_month(wrapper.to_time)
|
124
|
+
sum += dim
|
125
|
+
wrapper.add(:day, dim)
|
126
|
+
end
|
127
|
+
# now we can move to the desired day
|
128
|
+
dim = days_in_month(wrapper.to_time)
|
129
|
+
if desired_day > dim
|
130
|
+
sum -= desired_day - dim
|
131
|
+
end
|
132
|
+
sum
|
133
|
+
end
|
134
|
+
|
135
|
+
# Given a year, return a boolean indicating whether it is
|
136
|
+
# a leap year or not
|
137
|
+
def self.is_leap?(year)
|
138
|
+
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
139
|
+
end
|
140
|
+
|
141
|
+
# A utility class for safely moving time around
|
142
|
+
class TimeWrapper
|
143
|
+
|
144
|
+
def initialize(time, dst_adjust = true)
|
145
|
+
@dst_adjust = dst_adjust
|
146
|
+
@time = time
|
147
|
+
end
|
148
|
+
|
149
|
+
# Get the wrapper time back
|
150
|
+
def to_time
|
151
|
+
@time
|
152
|
+
end
|
153
|
+
|
154
|
+
# DST-safely add an interval of time to the wrapped time
|
155
|
+
def add(type, val)
|
156
|
+
type = :day if type == :wday
|
157
|
+
adjust do
|
158
|
+
@time += case type
|
159
|
+
when :year then TimeUtil.days_in_n_years(@time, val) * ONE_DAY
|
160
|
+
when :month then TimeUtil.days_in_n_months(@time, val) * ONE_DAY
|
161
|
+
when :day then val * ONE_DAY
|
162
|
+
when :hour then val * ONE_HOUR
|
163
|
+
when :min then val * ONE_MINUTE
|
164
|
+
when :sec then val
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Clear everything below a certain type
|
170
|
+
CLEAR_ORDER = [:sec, :min, :hour, :day, :month, :year]
|
171
|
+
def clear_below(type)
|
172
|
+
type = :day if type == :wday
|
173
|
+
CLEAR_ORDER.each do |ptype|
|
174
|
+
break if ptype == type
|
175
|
+
adjust do
|
176
|
+
send(:"clear_#{ptype}")
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
private
|
182
|
+
|
183
|
+
def adjust(&block)
|
184
|
+
if @dst_adjust
|
185
|
+
off = @time.utc_offset
|
186
|
+
yield
|
187
|
+
diff = off - @time.utc_offset
|
188
|
+
@time += diff if diff != 0
|
189
|
+
else
|
190
|
+
yield
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def clear_sec
|
195
|
+
@time -= @time.sec
|
196
|
+
end
|
197
|
+
|
198
|
+
def clear_min
|
199
|
+
@time -= (@time.min * ONE_MINUTE)
|
200
|
+
end
|
201
|
+
|
202
|
+
def clear_hour
|
203
|
+
@time -= (@time.hour * ONE_HOUR)
|
204
|
+
end
|
205
|
+
|
206
|
+
# Move to the first of the month, 0 hours
|
207
|
+
def clear_day
|
208
|
+
@time -= (@time.day - 1) * IceCube::ONE_DAY
|
209
|
+
end
|
210
|
+
|
211
|
+
# Clear to january 1st
|
212
|
+
def clear_month
|
213
|
+
@time -= ONE_DAY
|
214
|
+
until @time.month == 12
|
215
|
+
@time -= TimeUtil.days_in_month(@time) * ONE_DAY
|
216
|
+
end
|
217
|
+
@time += ONE_DAY
|
218
|
+
end
|
219
|
+
|
220
|
+
def clear_year
|
221
|
+
end
|
222
|
+
|
223
|
+
end
|
224
|
+
|
99
225
|
end
|
100
226
|
|
101
227
|
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module IceCube
|
2
|
+
|
3
|
+
class ValidatedRule < Rule
|
4
|
+
|
5
|
+
include Validations::ScheduleLock
|
6
|
+
|
7
|
+
include Validations::HourOfDay
|
8
|
+
include Validations::MinuteOfHour
|
9
|
+
include Validations::SecondOfMinute
|
10
|
+
include Validations::DayOfMonth
|
11
|
+
include Validations::DayOfWeek
|
12
|
+
include Validations::Day
|
13
|
+
include Validations::MonthOfYear
|
14
|
+
include Validations::DayOfYear
|
15
|
+
|
16
|
+
include Validations::Count
|
17
|
+
include Validations::Until
|
18
|
+
|
19
|
+
# Compute the next time after (or including) the specified time in respect
|
20
|
+
# to the given schedule
|
21
|
+
# NOTE: optimization target, sort the rules by their type, year first
|
22
|
+
# so we can make bigger jumps more often
|
23
|
+
def next_time(time, schedule, closing_time)
|
24
|
+
loop do
|
25
|
+
break if @validations.all? do |name, vals|
|
26
|
+
# Execute each validation
|
27
|
+
res = vals.map do |validation|
|
28
|
+
validation.validate(time, schedule)
|
29
|
+
end
|
30
|
+
# If there is any nil, then we're set - otherwise choose the lowest
|
31
|
+
if res.any? { |r| r.nil? || r == 0 }
|
32
|
+
true
|
33
|
+
else
|
34
|
+
return nil if res.all? { |r| r === true } # allow quick escaping
|
35
|
+
res.reject! { |r| r.nil? || r == 0 || r === true }
|
36
|
+
if fwd = res.min
|
37
|
+
type = vals.first.type # get the jump type
|
38
|
+
dst_adjust = !vals.first.respond_to?(:dst_adjust?) || vals.first.dst_adjust?
|
39
|
+
wrapper = TimeUtil::TimeWrapper.new(time, dst_adjust)
|
40
|
+
wrapper.add(type, fwd)
|
41
|
+
wrapper.clear_below(type)
|
42
|
+
time = wrapper.to_time
|
43
|
+
end
|
44
|
+
false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
# Prevent a non-matching infinite loop
|
48
|
+
return nil if closing_time && time > closing_time
|
49
|
+
end
|
50
|
+
# NOTE Uses may be 1 higher than proper here since end_time isn't validated
|
51
|
+
# in this class. This is okay now, since we never expose it - but if we ever
|
52
|
+
# do - we should check that above this line, and return nil if end_time is past
|
53
|
+
@uses += 1 if time
|
54
|
+
time
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_s
|
58
|
+
builder = StringBuilder.new
|
59
|
+
@validations.each do |name, validations|
|
60
|
+
validations.each do |validation|
|
61
|
+
validation.build_s(builder)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
builder.to_s
|
65
|
+
end
|
66
|
+
|
67
|
+
def to_hash
|
68
|
+
builder = HashBuilder.new(self)
|
69
|
+
@validations.each do |name, validations|
|
70
|
+
validations.each do |validation|
|
71
|
+
validation.build_hash(builder)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
builder.to_hash
|
75
|
+
end
|
76
|
+
|
77
|
+
def to_ical
|
78
|
+
builder = IcalBuilder.new
|
79
|
+
@validations.each do |name, validations|
|
80
|
+
validations.each do |validation|
|
81
|
+
validation.build_ical(builder)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
builder.to_s
|
85
|
+
end
|
86
|
+
|
87
|
+
# Get the collection that contains validations of a certain type
|
88
|
+
def validations_for(key)
|
89
|
+
@validations ||= {}
|
90
|
+
@validations[key] ||= []
|
91
|
+
end
|
92
|
+
|
93
|
+
# Fully replace validations
|
94
|
+
def replace_validations_for(key, arr)
|
95
|
+
@validations[key] = arr
|
96
|
+
end
|
97
|
+
|
98
|
+
# Remove the specified base validations
|
99
|
+
def clobber_base_validations(*types)
|
100
|
+
types.each do |type|
|
101
|
+
@validations.delete(:"base_#{type}")
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module IceCube
|
2
|
+
|
3
|
+
module Validations::Count
|
4
|
+
|
5
|
+
# accessor
|
6
|
+
def occurrence_count
|
7
|
+
@count
|
8
|
+
end
|
9
|
+
|
10
|
+
def count(max)
|
11
|
+
@count = max
|
12
|
+
replace_validations_for(:count, [Validation.new(max, self)]) # replace
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
class Validation
|
17
|
+
|
18
|
+
attr_reader :rule, :count
|
19
|
+
|
20
|
+
def initialize(count, rule)
|
21
|
+
@count = count
|
22
|
+
@rule = rule
|
23
|
+
end
|
24
|
+
|
25
|
+
def type
|
26
|
+
:dealbreaker
|
27
|
+
end
|
28
|
+
|
29
|
+
def validate(time, schedule)
|
30
|
+
if rule.uses && rule.uses >= count
|
31
|
+
raise CountExceeded
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def build_s(builder)
|
36
|
+
builder.piece(:count) << count
|
37
|
+
end
|
38
|
+
|
39
|
+
def build_hash(builder)
|
40
|
+
builder[:count] = count
|
41
|
+
end
|
42
|
+
|
43
|
+
def build_ical(builder)
|
44
|
+
builder['COUNT'] << count
|
45
|
+
end
|
46
|
+
|
47
|
+
StringBuilder.register_formatter(:count) do |segments|
|
48
|
+
count = segments.first
|
49
|
+
"#{count} #{count == 1 ? 'time' : 'times'}"
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|