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,414 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module IceCube
|
4
|
+
|
5
|
+
class Schedule
|
6
|
+
|
7
|
+
extend ::Deprecated
|
8
|
+
|
9
|
+
# Get the start time
|
10
|
+
attr_accessor :start_time
|
11
|
+
deprecated_alias :start_date, :start_time
|
12
|
+
deprecated_alias :start_date=, :start_time=
|
13
|
+
|
14
|
+
# Get the duration
|
15
|
+
attr_accessor :duration
|
16
|
+
|
17
|
+
# Get the end time
|
18
|
+
attr_accessor :end_time
|
19
|
+
deprecated_alias :end_date, :end_time
|
20
|
+
deprecated_alias :end_date=, :end_time=
|
21
|
+
|
22
|
+
# Create a new schedule
|
23
|
+
def initialize(start_time = nil, options = {})
|
24
|
+
@start_time = start_time || Time.now
|
25
|
+
@end_time = options[:end_time]
|
26
|
+
@duration = options[:duration]
|
27
|
+
@all_recurrence_rules = []
|
28
|
+
@all_exception_rules = []
|
29
|
+
end
|
30
|
+
|
31
|
+
# Add a recurrence time to the schedule
|
32
|
+
def add_recurrence_time(time)
|
33
|
+
return nil if time.nil?
|
34
|
+
rule = SingleOccurrenceRule.new(time)
|
35
|
+
add_recurrence_rule rule
|
36
|
+
time
|
37
|
+
end
|
38
|
+
alias :rtime :add_recurrence_time
|
39
|
+
deprecated_alias :rdate, :rtime
|
40
|
+
deprecated_alias :add_recurrence_date, :add_recurrence_time
|
41
|
+
|
42
|
+
# Add an exception time to the schedule
|
43
|
+
def add_exception_time(time)
|
44
|
+
return nil if time.nil?
|
45
|
+
rule = SingleOccurrenceRule.new(time)
|
46
|
+
add_exception_rule rule
|
47
|
+
time
|
48
|
+
end
|
49
|
+
alias :extime :add_exception_time
|
50
|
+
deprecated_alias :exdate, :extime
|
51
|
+
deprecated_alias :add_exception_date, :add_exception_time
|
52
|
+
|
53
|
+
# Add a recurrence rule to the schedule
|
54
|
+
def add_recurrence_rule(rule)
|
55
|
+
@all_recurrence_rules << rule unless @all_recurrence_rules.include?(rule)
|
56
|
+
end
|
57
|
+
alias :rrule :add_recurrence_rule
|
58
|
+
|
59
|
+
# Remove a recurrence rule
|
60
|
+
def remove_recurrence_rule(rule)
|
61
|
+
res = @all_recurrence_rules.delete(rule)
|
62
|
+
res.nil? ? [] : [res]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Add an exception rule to the schedule
|
66
|
+
def add_exception_rule(rule)
|
67
|
+
@all_exception_rules << rule unless @all_exception_rules.include?(rule)
|
68
|
+
end
|
69
|
+
alias :exrule :add_exception_rule
|
70
|
+
|
71
|
+
# Remove an exception rule
|
72
|
+
def remove_exception_rule(rule)
|
73
|
+
res = @all_exception_rules.delete(rule)
|
74
|
+
res.nil? ? [] : [res]
|
75
|
+
end
|
76
|
+
|
77
|
+
# Get the recurrence rules
|
78
|
+
def recurrence_rules
|
79
|
+
@all_recurrence_rules.reject { |r| r.is_a?(SingleOccurrenceRule) }
|
80
|
+
end
|
81
|
+
alias :rrules :recurrence_rules
|
82
|
+
|
83
|
+
# Get the exception rules
|
84
|
+
def exception_rules
|
85
|
+
@all_exception_rules.reject { |r| r.is_a?(SingleOccurrenceRule) }
|
86
|
+
end
|
87
|
+
alias :exrules :exception_rules
|
88
|
+
|
89
|
+
# Get the recurrence times that are on the schedule
|
90
|
+
def recurrence_times
|
91
|
+
@all_recurrence_rules.select { |r| r.is_a?(SingleOccurrenceRule) }.map(&:time)
|
92
|
+
end
|
93
|
+
alias :rtimes :recurrence_times
|
94
|
+
deprecated_alias :rdates, :rtimes
|
95
|
+
deprecated_alias :recurrence_dates, :recurrence_times
|
96
|
+
|
97
|
+
# Remove a recurrence time
|
98
|
+
def remove_recurrence_time(time)
|
99
|
+
found = false
|
100
|
+
@all_recurrence_rules.delete_if do |rule|
|
101
|
+
found = true if rule.is_a?(SingleOccurrenceRule) && rule.time == time
|
102
|
+
end
|
103
|
+
time if found
|
104
|
+
end
|
105
|
+
alias :remove_rtime :remove_recurrence_time
|
106
|
+
deprecated_alias :remove_recurrence_date, :remove_recurrence_time
|
107
|
+
deprecated_alias :remove_rdate, :remove_rtime
|
108
|
+
|
109
|
+
# Get the exception times that are on the schedule
|
110
|
+
def exception_times
|
111
|
+
@all_exception_rules.select { |r| r.is_a?(SingleOccurrenceRule) }.map(&:time)
|
112
|
+
end
|
113
|
+
alias :extimes :exception_times
|
114
|
+
deprecated_alias :exdates, :extimes
|
115
|
+
deprecated_alias :exception_dates, :exception_times
|
116
|
+
|
117
|
+
# Remove an exception time
|
118
|
+
def remove_exception_time(time)
|
119
|
+
found = false
|
120
|
+
@all_exception_rules.delete_if do |rule|
|
121
|
+
found = true if rule.is_a?(SingleOccurrenceRule) && rule.time == time
|
122
|
+
end
|
123
|
+
time if found
|
124
|
+
end
|
125
|
+
alias :remove_extime :remove_exception_time
|
126
|
+
deprecated_alias :remove_exception_date, :remove_exception_time
|
127
|
+
deprecated_alias :remove_exdate, :remove_extime
|
128
|
+
|
129
|
+
# Get all of the occurrences from the start_time up until a
|
130
|
+
# given Time
|
131
|
+
def occurrences(closing_time)
|
132
|
+
find_occurrences(start_time, closing_time)
|
133
|
+
end
|
134
|
+
|
135
|
+
# All of the occurrences
|
136
|
+
def all_occurrences
|
137
|
+
raise ArgumentError.new('Rule must specify either an until date or a count to use #all_occurrences') unless terminating?
|
138
|
+
find_occurrences(start_time)
|
139
|
+
end
|
140
|
+
|
141
|
+
# Iterate forever
|
142
|
+
def each_occurrence(&block)
|
143
|
+
find_occurrences(start_time, &block)
|
144
|
+
self
|
145
|
+
end
|
146
|
+
|
147
|
+
# The next n occurrences after now
|
148
|
+
def next_occurrences(num, from = Time.now)
|
149
|
+
find_occurrences(from + 1, nil, num)
|
150
|
+
end
|
151
|
+
|
152
|
+
# The next occurrence after now (overridable)
|
153
|
+
def next_occurrence(from = Time.now)
|
154
|
+
find_occurrences(from + 1, nil, 1).first
|
155
|
+
end
|
156
|
+
|
157
|
+
# The remaining occurrences (same requirements as all_occurrences)
|
158
|
+
def remaining_occurrences(from = Time.now)
|
159
|
+
find_occurrences(from)
|
160
|
+
end
|
161
|
+
|
162
|
+
# Occurrences between two times
|
163
|
+
def occurrences_between(begin_time, closing_time)
|
164
|
+
find_occurrences(begin_time, closing_time)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Return a boolean indicating if an occurrence falls between
|
168
|
+
# two times
|
169
|
+
def occurs_between?(begin_time, closing_time)
|
170
|
+
!find_occurrences(begin_time, closing_time, 1).empty?
|
171
|
+
end
|
172
|
+
|
173
|
+
# Return a boolean indicating if an occurrence falls on a certain date
|
174
|
+
def occurs_on?(date)
|
175
|
+
begin_time = TimeUtil.beginning_of_date(date)
|
176
|
+
closing_time = TimeUtil.end_of_date(date)
|
177
|
+
occurs_between?(begin_time, closing_time)
|
178
|
+
end
|
179
|
+
|
180
|
+
# Determine if the schedule is occurring at a given time
|
181
|
+
def occurring_at?(time)
|
182
|
+
time = time.to_time
|
183
|
+
if duration
|
184
|
+
return false if exception_time?(time)
|
185
|
+
occurs_between?(time - duration + 1, time)
|
186
|
+
else
|
187
|
+
occurs_at?(time)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
# Determine if this schedule conflicts with another schedule
|
192
|
+
# @param [IceCube::Schedule] other_schedule - The schedule to compare to
|
193
|
+
# @param [Time] closing_time - the last time to consider
|
194
|
+
# @return [Boolean] whether or not the schedules conflict at all
|
195
|
+
def conflicts_with?(other_schedule, closing_time = nil)
|
196
|
+
unless terminating? || other_schedule.terminating? || closing_time
|
197
|
+
raise ArgumentError.new 'At least one schedule must be terminating to use #conflicts_with?'
|
198
|
+
end
|
199
|
+
# Pick the terminating schedule, and other schedule
|
200
|
+
# No need to reverse if terminating? or there is a closing time
|
201
|
+
terminating_schedule = self
|
202
|
+
unless terminating? || closing_time
|
203
|
+
terminating_schedule, other_schedule = other_schedule, terminating_schedule
|
204
|
+
end
|
205
|
+
# Go through each occurrence of the terminating schedule and determine
|
206
|
+
# if the other occurs at that time
|
207
|
+
last_time = nil
|
208
|
+
terminating_schedule.each_occurrence do |time|
|
209
|
+
if closing_time && time > closing_time
|
210
|
+
last_time = closing_time
|
211
|
+
break
|
212
|
+
end
|
213
|
+
last_time = time
|
214
|
+
return true if other_schedule.occurring_at?(time)
|
215
|
+
end
|
216
|
+
# Due to durations, we need to walk up to the end time, and verify in the
|
217
|
+
# other direction
|
218
|
+
if last_time
|
219
|
+
last_time = terminating_schedule.duration ? last_time + terminating_schedule.duration : last_time
|
220
|
+
other_schedule.each_occurrence do |time|
|
221
|
+
break if time > last_time
|
222
|
+
return true if terminating_schedule.occurring_at?(time)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
# No conflict, return false
|
226
|
+
false
|
227
|
+
end
|
228
|
+
|
229
|
+
# Determine if the schedule occurs at a specific time
|
230
|
+
def occurs_at?(time)
|
231
|
+
occurs_between?(time, time)
|
232
|
+
end
|
233
|
+
|
234
|
+
# Get the first n occurrences, or the first occurrence if n is skipped
|
235
|
+
def first(n = nil)
|
236
|
+
occurrences = find_occurrences start_time, nil, n || 1
|
237
|
+
n.nil? ? occurrences.first : occurrences
|
238
|
+
end
|
239
|
+
|
240
|
+
# String serialization
|
241
|
+
def to_s
|
242
|
+
pieces = []
|
243
|
+
ed = extimes; rd = rtimes - ed
|
244
|
+
pieces.concat rd.sort.map { |t| t.strftime(TO_S_TIME_FORMAT) }
|
245
|
+
pieces.concat rrules.map { |t| t.to_s }
|
246
|
+
pieces.concat exrules.map { |t| "not #{t.to_s}" }
|
247
|
+
pieces.concat ed.sort.map { |t| "not on #{t.strftime(TO_S_TIME_FORMAT)}" }
|
248
|
+
pieces << "until #{end_time.strftime(TO_S_TIME_FORMAT)}" if end_time
|
249
|
+
pieces.join(' / ')
|
250
|
+
end
|
251
|
+
|
252
|
+
# Serialize this schedule to_ical
|
253
|
+
def to_ical(force_utc = false)
|
254
|
+
pieces = []
|
255
|
+
pieces << "DTSTART#{IcalBuilder.ical_format(start_time, force_utc)}"
|
256
|
+
pieces << "DURATION:#{IcalBuilder.ical_duration(duration)}" if duration
|
257
|
+
pieces.concat recurrence_rules.map { |r| "RRULE:#{r.to_ical}" }
|
258
|
+
pieces.concat exception_rules.map { |r| "EXRULE:#{r.to_ical}" }
|
259
|
+
pieces.concat recurrence_times.map { |t| "RDATE#{IcalBuilder.ical_format(t, force_utc)}" }
|
260
|
+
pieces.concat exception_times.map { |t| "EXDATE#{IcalBuilder.ical_format(t, force_utc)}" }
|
261
|
+
pieces << "DTEND#{IcalBuilder.ical_format(end_time, force_utc)}" if end_time
|
262
|
+
pieces.join("\n")
|
263
|
+
end
|
264
|
+
|
265
|
+
# Convert the schedule to yaml
|
266
|
+
def to_yaml(*args)
|
267
|
+
IceCube::use_psych? ? Psych::dump(to_hash, *args) : YAML::dump(to_hash, *args)
|
268
|
+
end
|
269
|
+
|
270
|
+
# Load the schedule from yaml
|
271
|
+
def self.from_yaml(yaml, options = {})
|
272
|
+
from_hash IceCube::use_psych? ? Psych::load(yaml) : YAML::load(yaml), options
|
273
|
+
end
|
274
|
+
|
275
|
+
# Convert the schedule to a hash
|
276
|
+
def to_hash
|
277
|
+
data = {}
|
278
|
+
data[:start_date] = TimeUtil.serialize_time(start_time)
|
279
|
+
data[:end_time] = TimeUtil.serialize_time(end_time) if end_time
|
280
|
+
data[:duration] = duration if duration
|
281
|
+
data[:rrules] = recurrence_rules.map(&:to_hash)
|
282
|
+
data[:exrules] = exception_rules.map(&:to_hash)
|
283
|
+
data[:rtimes] = recurrence_times.map do |rt|
|
284
|
+
TimeUtil.serialize_time(rt)
|
285
|
+
end
|
286
|
+
data[:extimes] = exception_times.map do |et|
|
287
|
+
TimeUtil.serialize_time(et)
|
288
|
+
end
|
289
|
+
data
|
290
|
+
end
|
291
|
+
|
292
|
+
# Load the schedule from a hash
|
293
|
+
def self.from_hash(data, options = {})
|
294
|
+
data[:start_date] = options[:start_date_override] if options[:start_date_override]
|
295
|
+
# And then deserialize
|
296
|
+
schedule = IceCube::Schedule.new TimeUtil.deserialize_time(data[:start_date])
|
297
|
+
schedule.duration = data[:duration] if data[:duration]
|
298
|
+
schedule.end_time = TimeUtil.deserialize_time(data[:end_time]) if data[:end_time]
|
299
|
+
data[:rrules] && data[:rrules].each { |h| schedule.rrule(IceCube::Rule.from_hash(h)) }
|
300
|
+
data[:exrules] && data[:exrules].each { |h| schedule.exrule(IceCube::Rule.from_hash(h)) }
|
301
|
+
data[:rtimes] && data[:rtimes].each do |t|
|
302
|
+
schedule.add_recurrence_time TimeUtil.deserialize_time(t)
|
303
|
+
end
|
304
|
+
data[:extimes] && data[:extimes].each do |t|
|
305
|
+
schedule.add_exception_time TimeUtil.deserialize_time(t)
|
306
|
+
end
|
307
|
+
# Also serialize old format for backward compat
|
308
|
+
data[:rdates] && data[:rdates].each do |t|
|
309
|
+
schedule.add_recurrence_time TimeUtil.deserialize_time(t)
|
310
|
+
end
|
311
|
+
data[:exdates] && data[:exdates].each do |t|
|
312
|
+
schedule.add_exception_time TimeUtil.deserialize_time(t)
|
313
|
+
end
|
314
|
+
schedule
|
315
|
+
end
|
316
|
+
|
317
|
+
# Determine if the schedule will end
|
318
|
+
# @return [Boolean] true if ending, false if repeating forever
|
319
|
+
def terminating?
|
320
|
+
end_time || recurrence_rules.all?(&:terminating?)
|
321
|
+
end
|
322
|
+
|
323
|
+
def self.dump(schedule)
|
324
|
+
schedule.to_yaml
|
325
|
+
end
|
326
|
+
|
327
|
+
def self.load(yaml)
|
328
|
+
from_yaml(yaml) unless yaml.nil? || yaml.empty?
|
329
|
+
end
|
330
|
+
|
331
|
+
private
|
332
|
+
|
333
|
+
# Reset all rules for another run
|
334
|
+
def reset
|
335
|
+
@all_recurrence_rules.each(&:reset)
|
336
|
+
@all_exception_rules.each(&:reset)
|
337
|
+
end
|
338
|
+
|
339
|
+
# Find all of the occurrences for the schedule between opening_time
|
340
|
+
# and closing_time
|
341
|
+
def find_occurrences(opening_time, closing_time = nil, limit = nil, &block)
|
342
|
+
reset
|
343
|
+
answers = []
|
344
|
+
# ensure the bounds are proper
|
345
|
+
if end_time
|
346
|
+
closing_time = end_time unless closing_time && closing_time < end_time
|
347
|
+
end
|
348
|
+
opening_time = start_time if opening_time < start_time
|
349
|
+
# walk up to the opening time - and off we go
|
350
|
+
# If we have rules with counts, we need to walk from the beginning of time,
|
351
|
+
# otherwise opening_time
|
352
|
+
time = full_required? ? start_time : opening_time
|
353
|
+
loop do
|
354
|
+
res = next_time(time, closing_time)
|
355
|
+
break unless res
|
356
|
+
break if closing_time && res > closing_time
|
357
|
+
if res >= opening_time
|
358
|
+
block_given? ? block.call(res) : (answers << res)
|
359
|
+
break if limit && answers.length == limit
|
360
|
+
end
|
361
|
+
time = res + 1
|
362
|
+
end
|
363
|
+
# and return our answers
|
364
|
+
answers
|
365
|
+
end
|
366
|
+
|
367
|
+
# Get the next time after (or including) a specific time
|
368
|
+
def next_time(time, closing_time)
|
369
|
+
min_time = nil
|
370
|
+
loop do
|
371
|
+
@all_recurrence_rules.each do |rule|
|
372
|
+
begin
|
373
|
+
if res = rule.next_time(time, self, closing_time)
|
374
|
+
if min_time.nil? || res < min_time
|
375
|
+
min_time = res
|
376
|
+
end
|
377
|
+
end
|
378
|
+
# Certain exceptions mean this rule no longer wants to play
|
379
|
+
rescue CountExceeded, UntilExceeded
|
380
|
+
next
|
381
|
+
end
|
382
|
+
end
|
383
|
+
# If there is no match, return nil
|
384
|
+
return nil unless min_time
|
385
|
+
# Now make sure that its not an exception_time, and if it is
|
386
|
+
# then keep looking
|
387
|
+
if exception_time?(min_time)
|
388
|
+
time = min_time + 1
|
389
|
+
min_time = nil
|
390
|
+
next
|
391
|
+
end
|
392
|
+
# Break, we're done
|
393
|
+
break
|
394
|
+
end
|
395
|
+
min_time
|
396
|
+
end
|
397
|
+
|
398
|
+
# Return a boolean indicating if any rule needs to be run from the start of time
|
399
|
+
def full_required?
|
400
|
+
@all_recurrence_rules.any?(&:full_required?) ||
|
401
|
+
@all_exception_rules.any?(&:full_required?)
|
402
|
+
end
|
403
|
+
|
404
|
+
# Return a boolean indicating whether or not a specific time
|
405
|
+
# is excluded from the schedule
|
406
|
+
def exception_time?(time)
|
407
|
+
@all_exception_rules.any? do |rule|
|
408
|
+
rule.on?(time, self)
|
409
|
+
end
|
410
|
+
end
|
411
|
+
|
412
|
+
end
|
413
|
+
|
414
|
+
end
|