ice_cube_conrad 0.8.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 +80 -0
- 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/deprecated.rb +28 -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/errors/zero_interval.rb +7 -0
- data/lib/ice_cube/rule.rb +182 -0
- data/lib/ice_cube/rules/daily_rule.rb +14 -0
- data/lib/ice_cube/rules/hourly_rule.rb +14 -0
- data/lib/ice_cube/rules/minutely_rule.rb +14 -0
- data/lib/ice_cube/rules/monthly_rule.rb +14 -0
- data/lib/ice_cube/rules/secondly_rule.rb +13 -0
- data/lib/ice_cube/rules/weekly_rule.rb +14 -0
- data/lib/ice_cube/rules/yearly_rule.rb +14 -0
- data/lib/ice_cube/schedule.rb +414 -0
- data/lib/ice_cube/single_occurrence_rule.rb +28 -0
- data/lib/ice_cube/time_util.rb +250 -0
- data/lib/ice_cube/validated_rule.rb +108 -0
- data/lib/ice_cube/validations/count.rb +56 -0
- data/lib/ice_cube/validations/daily_interval.rb +55 -0
- data/lib/ice_cube/validations/day.rb +65 -0
- data/lib/ice_cube/validations/day_of_month.rb +52 -0
- data/lib/ice_cube/validations/day_of_week.rb +70 -0
- data/lib/ice_cube/validations/day_of_year.rb +55 -0
- data/lib/ice_cube/validations/hour_of_day.rb +52 -0
- data/lib/ice_cube/validations/hourly_interval.rb +57 -0
- data/lib/ice_cube/validations/lock.rb +47 -0
- data/lib/ice_cube/validations/minute_of_hour.rb +51 -0
- data/lib/ice_cube/validations/minutely_interval.rb +57 -0
- data/lib/ice_cube/validations/month_of_year.rb +49 -0
- data/lib/ice_cube/validations/monthly_interval.rb +51 -0
- data/lib/ice_cube/validations/schedule_lock.rb +41 -0
- data/lib/ice_cube/validations/second_of_minute.rb +49 -0
- data/lib/ice_cube/validations/secondly_interval.rb +54 -0
- data/lib/ice_cube/validations/until.rb +51 -0
- data/lib/ice_cube/validations/weekly_interval.rb +60 -0
- data/lib/ice_cube/validations/yearly_interval.rb +49 -0
- data/lib/ice_cube/version.rb +5 -0
- data/spec/spec_helper.rb +11 -0
- metadata +120 -0
@@ -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
|
@@ -0,0 +1,250 @@
|
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module IceCube
|
4
|
+
|
5
|
+
module TimeUtil
|
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
|
+
ICAL_DAYS = {
|
16
|
+
'SU' => :sunday, 'MO' => :monday, 'TU' => :tuesday, 'WE' => :wednesday,
|
17
|
+
'TH' => :thursday, 'FR' => :friday, 'SA' => :saturday
|
18
|
+
}
|
19
|
+
|
20
|
+
MONTHS = {
|
21
|
+
:january => 1, :february => 2, :march => 3, :april => 4, :may => 5,
|
22
|
+
:june => 6, :july => 7, :august => 8, :september => 9, :october => 10,
|
23
|
+
:november => 11, :december => 12
|
24
|
+
}
|
25
|
+
|
26
|
+
# Serialize a time appropriate for storing
|
27
|
+
def self.serialize_time(time)
|
28
|
+
if defined?(:ActiveSupport) && const_defined?(:ActiveSupport) && time.is_a?(ActiveSupport::TimeWithZone)
|
29
|
+
{ :time => time.utc, :zone => time.time_zone.name }
|
30
|
+
elsif time.is_a?(Time)
|
31
|
+
time
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Deserialize a time serialized with serialize_time
|
36
|
+
def self.deserialize_time(time_or_hash)
|
37
|
+
if time_or_hash.is_a?(Time)
|
38
|
+
time_or_hash
|
39
|
+
elsif time_or_hash.is_a?(Hash)
|
40
|
+
time_or_hash[:time].in_time_zone(time_or_hash[:zone])
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get the beginning of a date
|
45
|
+
def self.beginning_of_date(date)
|
46
|
+
date.respond_to?(:beginning_of_day) ?
|
47
|
+
date.beginning_of_day :
|
48
|
+
Time.local(date.year, date.month, date.day, 0, 0, 0)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Get the end of a date
|
52
|
+
def self.end_of_date(date)
|
53
|
+
date.respond_to?(:end_of_day) ?
|
54
|
+
date.end_of_day :
|
55
|
+
Time.local(date.year, date.month, date.day, 23, 59, 59)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Convert a symbol to a numeric month
|
59
|
+
def self.symbol_to_month(sym)
|
60
|
+
month = MONTHS[sym]
|
61
|
+
raise "No such month: #{sym}" unless month
|
62
|
+
month
|
63
|
+
end
|
64
|
+
|
65
|
+
# Convert a symbol to a numeric day
|
66
|
+
def self.symbol_to_day(sym)
|
67
|
+
day = DAYS[sym]
|
68
|
+
raise "No such day: #{sym}" unless day
|
69
|
+
day
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.ical_day_to_symbol(str)
|
73
|
+
day = ICAL_DAYS[str]
|
74
|
+
raise "No such day: #{str}" if day.nil?
|
75
|
+
day
|
76
|
+
end
|
77
|
+
|
78
|
+
# Convert a symbol to an ical day (SU, MO)
|
79
|
+
def self.week_start(sym)
|
80
|
+
raise "No such day: #{sym}" unless DAYS.keys.include?(sym)
|
81
|
+
day = sym.to_s.upcase[0..1]
|
82
|
+
day
|
83
|
+
end
|
84
|
+
|
85
|
+
# Convert weekday from base sunday to the schedule's week start.
|
86
|
+
def self.normalize_weekday(daynum, week_start)
|
87
|
+
(daynum - symbol_to_day(week_start)) % 7
|
88
|
+
end
|
89
|
+
|
90
|
+
# Return the count of the number of times wday appears in the month,
|
91
|
+
# and which of those time falls on
|
92
|
+
def self.which_occurrence_in_month(time, wday)
|
93
|
+
first_occurrence = ((7 - Time.utc(time.year, time.month, 1).wday) + time.wday) % 7 + 1
|
94
|
+
this_weekday_in_month_count = ((days_in_month(time) - first_occurrence + 1) / 7.0).ceil
|
95
|
+
nth_occurrence_of_weekday = (time.mday - first_occurrence) / 7 + 1
|
96
|
+
[nth_occurrence_of_weekday, this_weekday_in_month_count]
|
97
|
+
end
|
98
|
+
|
99
|
+
# Get the days in the month for +time
|
100
|
+
def self.days_in_month(time)
|
101
|
+
days_in_month_year(time.month, time.year)
|
102
|
+
end
|
103
|
+
|
104
|
+
def self.days_in_next_month(time)
|
105
|
+
# Get the next month
|
106
|
+
year = time.year
|
107
|
+
month = time.month + 1
|
108
|
+
if month > 12
|
109
|
+
month %= 12
|
110
|
+
year += 1
|
111
|
+
end
|
112
|
+
# And then determine
|
113
|
+
days_in_month_year(month, year)
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.days_in_month_year(month, year)
|
117
|
+
is_leap?(year) ? LEAP_YEAR_MONTH_DAYS[month - 1] : COMMON_YEAR_MONTH_DAYS[month - 1]
|
118
|
+
end
|
119
|
+
|
120
|
+
# Number of days in a year
|
121
|
+
def self.days_in_year(time)
|
122
|
+
is_leap?(time.year) ? 366 : 365
|
123
|
+
end
|
124
|
+
|
125
|
+
# Number of days to n years
|
126
|
+
def self.days_in_n_years(time, year_distance)
|
127
|
+
sum = 0
|
128
|
+
wrapper = TimeWrapper.new(time)
|
129
|
+
year_distance.times do
|
130
|
+
diy = days_in_year(wrapper.to_time)
|
131
|
+
sum += diy
|
132
|
+
wrapper.add(:day, diy)
|
133
|
+
end
|
134
|
+
sum
|
135
|
+
end
|
136
|
+
|
137
|
+
# The number of days in n months
|
138
|
+
def self.days_in_n_months(time, month_distance)
|
139
|
+
# move to a safe spot in the month to make this computation
|
140
|
+
desired_day = time.day
|
141
|
+
time -= IceCube::ONE_DAY * (time.day - 27) if time.day >= 28
|
142
|
+
# move n months ahead
|
143
|
+
sum = 0
|
144
|
+
wrapper = TimeWrapper.new(time)
|
145
|
+
month_distance.times do
|
146
|
+
dim = days_in_month(wrapper.to_time)
|
147
|
+
sum += dim
|
148
|
+
wrapper.add(:day, dim)
|
149
|
+
end
|
150
|
+
# now we can move to the desired day
|
151
|
+
dim = days_in_month(wrapper.to_time)
|
152
|
+
if desired_day > dim
|
153
|
+
sum -= desired_day - dim
|
154
|
+
end
|
155
|
+
sum
|
156
|
+
end
|
157
|
+
|
158
|
+
# Given a year, return a boolean indicating whether it is
|
159
|
+
# a leap year or not
|
160
|
+
def self.is_leap?(year)
|
161
|
+
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
162
|
+
end
|
163
|
+
|
164
|
+
# A utility class for safely moving time around
|
165
|
+
class TimeWrapper
|
166
|
+
|
167
|
+
def initialize(time, dst_adjust = true)
|
168
|
+
@dst_adjust = dst_adjust
|
169
|
+
@time = time
|
170
|
+
end
|
171
|
+
|
172
|
+
# Get the wrapper time back
|
173
|
+
def to_time
|
174
|
+
@time
|
175
|
+
end
|
176
|
+
|
177
|
+
# DST-safely add an interval of time to the wrapped time
|
178
|
+
def add(type, val)
|
179
|
+
type = :day if type == :wday
|
180
|
+
adjust do
|
181
|
+
@time += case type
|
182
|
+
when :year then TimeUtil.days_in_n_years(@time, val) * ONE_DAY
|
183
|
+
when :month then TimeUtil.days_in_n_months(@time, val) * ONE_DAY
|
184
|
+
when :day then val * ONE_DAY
|
185
|
+
when :hour then val * ONE_HOUR
|
186
|
+
when :min then val * ONE_MINUTE
|
187
|
+
when :sec then val
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
# Clear everything below a certain type
|
193
|
+
CLEAR_ORDER = [:sec, :min, :hour, :day, :month, :year]
|
194
|
+
def clear_below(type)
|
195
|
+
type = :day if type == :wday
|
196
|
+
CLEAR_ORDER.each do |ptype|
|
197
|
+
break if ptype == type
|
198
|
+
adjust do
|
199
|
+
send(:"clear_#{ptype}")
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
private
|
205
|
+
|
206
|
+
def adjust(&block)
|
207
|
+
if @dst_adjust
|
208
|
+
off = @time.utc_offset
|
209
|
+
yield
|
210
|
+
diff = off - @time.utc_offset
|
211
|
+
@time += diff if diff != 0
|
212
|
+
else
|
213
|
+
yield
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def clear_sec
|
218
|
+
@time -= @time.sec
|
219
|
+
end
|
220
|
+
|
221
|
+
def clear_min
|
222
|
+
@time -= (@time.min * ONE_MINUTE)
|
223
|
+
end
|
224
|
+
|
225
|
+
def clear_hour
|
226
|
+
@time -= (@time.hour * ONE_HOUR)
|
227
|
+
end
|
228
|
+
|
229
|
+
# Move to the first of the month, 0 hours
|
230
|
+
def clear_day
|
231
|
+
@time -= (@time.day - 1) * IceCube::ONE_DAY
|
232
|
+
end
|
233
|
+
|
234
|
+
# Clear to january 1st
|
235
|
+
def clear_month
|
236
|
+
@time -= ONE_DAY
|
237
|
+
until @time.month == 12
|
238
|
+
@time -= TimeUtil.days_in_month(@time) * ONE_DAY
|
239
|
+
end
|
240
|
+
@time += ONE_DAY
|
241
|
+
end
|
242
|
+
|
243
|
+
def clear_year
|
244
|
+
end
|
245
|
+
|
246
|
+
end
|
247
|
+
|
248
|
+
end
|
249
|
+
|
250
|
+
end
|
@@ -0,0 +1,108 @@
|
|
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
|
51
|
+
# validated in this class. This is okay now, since we never expose it -
|
52
|
+
# but if we ever do - we should check that above this line, and return
|
53
|
+
# nil if end_time is past
|
54
|
+
@uses += 1 if time
|
55
|
+
time
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_s
|
59
|
+
builder = StringBuilder.new
|
60
|
+
@validations.each do |name, validations|
|
61
|
+
validations.each do |validation|
|
62
|
+
validation.build_s(builder)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
builder.to_s
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_hash
|
69
|
+
builder = HashBuilder.new(self)
|
70
|
+
@validations.each do |name, validations|
|
71
|
+
validations.each do |validation|
|
72
|
+
validation.build_hash(builder)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
builder.to_hash
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_ical
|
79
|
+
builder = IcalBuilder.new
|
80
|
+
@validations.each do |name, validations|
|
81
|
+
validations.each do |validation|
|
82
|
+
validation.build_ical(builder)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
builder.to_s
|
86
|
+
end
|
87
|
+
|
88
|
+
# Get the collection that contains validations of a certain type
|
89
|
+
def validations_for(key)
|
90
|
+
@validations ||= {}
|
91
|
+
@validations[key] ||= []
|
92
|
+
end
|
93
|
+
|
94
|
+
# Fully replace validations
|
95
|
+
def replace_validations_for(key, arr)
|
96
|
+
@validations[key] = arr
|
97
|
+
end
|
98
|
+
|
99
|
+
# Remove the specified base validations
|
100
|
+
def clobber_base_validations(*types)
|
101
|
+
types.each do |type|
|
102
|
+
@validations.delete(:"base_#{type}")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
|
108
|
+
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
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module IceCube
|
2
|
+
|
3
|
+
module Validations::DailyInterval
|
4
|
+
|
5
|
+
# Add a new interval validation
|
6
|
+
def interval(interval)
|
7
|
+
validations_for(:interval) << Validation.new(interval)
|
8
|
+
clobber_base_validations(:wday, :day)
|
9
|
+
self
|
10
|
+
end
|
11
|
+
|
12
|
+
# A validation for checking to make sure that a time
|
13
|
+
# is inside of a certain DailyInterval
|
14
|
+
class Validation
|
15
|
+
|
16
|
+
attr_reader :interval
|
17
|
+
|
18
|
+
def initialize(interval)
|
19
|
+
@interval = interval
|
20
|
+
end
|
21
|
+
|
22
|
+
def build_s(builder)
|
23
|
+
builder.base = interval == 1 ? 'Daily' : "Every #{interval} days"
|
24
|
+
end
|
25
|
+
|
26
|
+
def build_hash(builder)
|
27
|
+
builder[:interval] = interval
|
28
|
+
end
|
29
|
+
|
30
|
+
def build_ical(builder)
|
31
|
+
builder['FREQ'] << 'DAILY'
|
32
|
+
unless interval == 1
|
33
|
+
builder['INTERVAL'] << interval
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def type
|
38
|
+
:day
|
39
|
+
end
|
40
|
+
|
41
|
+
def validate(time, schedule)
|
42
|
+
raise ZeroInterval if interval == 0
|
43
|
+
time_date = Date.new(time.year, time.month, time.day)
|
44
|
+
start_date = Date.new(schedule.start_time.year, schedule.start_time.month, schedule.start_time.day)
|
45
|
+
days = time_date - start_date
|
46
|
+
unless days % interval === 0
|
47
|
+
interval - (days % interval)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|