montrose 0.10.0 → 0.12.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +65 -0
- data/Appraisals +6 -14
- data/CHANGELOG.md +13 -1
- data/Guardfile +2 -2
- data/README.md +57 -0
- data/Rakefile +2 -4
- data/bin/setup +1 -0
- data/bin/standardrb +29 -0
- data/gemfiles/activesupport_5.2.gemfile +4 -0
- data/gemfiles/activesupport_6.0.gemfile +5 -1
- data/gemfiles/{activesupport_4.2.gemfile → activesupport_6.1.gemfile} +5 -1
- data/lib/montrose.rb +23 -3
- data/lib/montrose/chainable.rb +43 -9
- data/lib/montrose/clock.rb +4 -4
- data/lib/montrose/frequency.rb +4 -4
- data/lib/montrose/options.rb +40 -16
- data/lib/montrose/recurrence.rb +29 -8
- data/lib/montrose/rule.rb +2 -1
- data/lib/montrose/rule/between.rb +1 -1
- data/lib/montrose/rule/covering.rb +40 -0
- data/lib/montrose/rule/during.rb +55 -0
- data/lib/montrose/rule/until.rb +1 -1
- data/lib/montrose/schedule.rb +112 -24
- data/lib/montrose/stack.rb +3 -2
- data/lib/montrose/utils.rb +6 -6
- data/lib/montrose/version.rb +1 -1
- data/montrose.gemspec +17 -17
- metadata +35 -37
- data/.rubocop.yml +0 -136
- data/.travis.yml +0 -33
- data/bin/rubocop +0 -16
- data/gemfiles/activesupport_4.1.gemfile +0 -12
- data/gemfiles/activesupport_5.0.gemfile +0 -12
- data/gemfiles/activesupport_5.1.gemfile +0 -12
data/lib/montrose/clock.rb
CHANGED
@@ -26,7 +26,7 @@ module Montrose
|
|
26
26
|
if @at
|
27
27
|
times = @at.map { |hour, min, sec = 0| @time.change(hour: hour, min: min, sec: sec) }
|
28
28
|
|
29
|
-
min_next = times.select { |t| t > @time }.min
|
29
|
+
(min_next = times.select { |t| t > @time }.min) && (return min_next)
|
30
30
|
|
31
31
|
advance_step(times.min || @time)
|
32
32
|
else
|
@@ -41,7 +41,7 @@ module Montrose
|
|
41
41
|
end
|
42
42
|
|
43
43
|
def step
|
44
|
-
@step ||= smallest_step
|
44
|
+
(@step ||= smallest_step) || fail(ConfigurationError, "No step for #{@options.inspect}")
|
45
45
|
end
|
46
46
|
|
47
47
|
def smallest_step
|
@@ -76,9 +76,9 @@ module Montrose
|
|
76
76
|
is_frequency = @every == unit
|
77
77
|
if ([unit] + alternates).any? { |u| @options.key?(u) } && !is_frequency
|
78
78
|
# smallest unit, increment by 1
|
79
|
-
{
|
79
|
+
{step_key(unit) => 1}
|
80
80
|
elsif is_frequency
|
81
|
-
{
|
81
|
+
{step_key(unit) => @interval}
|
82
82
|
end
|
83
83
|
end
|
84
84
|
|
data/lib/montrose/frequency.rb
CHANGED
@@ -30,17 +30,17 @@ module Montrose
|
|
30
30
|
#
|
31
31
|
def self.from_options(opts)
|
32
32
|
frequency = opts.fetch(:every) { fail ConfigurationError, "Please specify the :every option" }
|
33
|
-
class_name = FREQUENCY_TERMS.fetch(frequency.to_s)
|
33
|
+
class_name = FREQUENCY_TERMS.fetch(frequency.to_s) {
|
34
34
|
fail "Don't know how to enumerate every: #{frequency}"
|
35
|
-
|
35
|
+
}
|
36
36
|
|
37
37
|
Montrose::Frequency.const_get(class_name).new(opts)
|
38
38
|
end
|
39
39
|
|
40
40
|
# @private
|
41
41
|
def self.assert(frequency)
|
42
|
-
FREQUENCY_TERMS.key?(frequency.to_s)
|
43
|
-
"Don't know how to enumerate every: #{frequency}"
|
42
|
+
FREQUENCY_TERMS.key?(frequency.to_s) || fail(ConfigurationError,
|
43
|
+
"Don't know how to enumerate every: #{frequency}")
|
44
44
|
|
45
45
|
frequency.to_sym
|
46
46
|
end
|
data/lib/montrose/options.rb
CHANGED
@@ -86,6 +86,8 @@ module Montrose
|
|
86
86
|
def_option :starts
|
87
87
|
def_option :until
|
88
88
|
def_option :between
|
89
|
+
def_option :covering
|
90
|
+
def_option :during
|
89
91
|
def_option :hour
|
90
92
|
def_option :day
|
91
93
|
def_option :mday
|
@@ -119,12 +121,12 @@ module Montrose
|
|
119
121
|
end
|
120
122
|
|
121
123
|
def to_hash
|
122
|
-
hash_pairs = self.class.defined_options.flat_map
|
124
|
+
hash_pairs = self.class.defined_options.flat_map { |opt_name|
|
123
125
|
[opt_name, send(opt_name)]
|
124
|
-
|
126
|
+
}
|
125
127
|
Hash[*hash_pairs].reject { |_k, v| v.nil? }
|
126
128
|
end
|
127
|
-
|
129
|
+
alias_method :to_h, :to_hash
|
128
130
|
|
129
131
|
def []=(option, val)
|
130
132
|
send(:"#{option}=", val)
|
@@ -141,13 +143,13 @@ module Montrose
|
|
141
143
|
self.class.new(h1.merge(h2))
|
142
144
|
end
|
143
145
|
|
144
|
-
def fetch(key, *args
|
145
|
-
|
146
|
+
def fetch(key, *args)
|
147
|
+
raise ArgumentError, "wrong number of arguments (#{args.length} for 1..2)" if args.length > 1
|
146
148
|
|
147
149
|
found = send(key)
|
148
150
|
return found if found
|
149
151
|
return args.first if args.length == 1
|
150
|
-
|
152
|
+
raise "Key #{key.inspect} not found" unless block
|
151
153
|
|
152
154
|
yield
|
153
155
|
end
|
@@ -164,8 +166,8 @@ module Montrose
|
|
164
166
|
@every = parsed.fetch(:every)
|
165
167
|
end
|
166
168
|
|
167
|
-
|
168
|
-
|
169
|
+
alias_method :frequency, :every
|
170
|
+
alias_method :frequency=, :every=
|
169
171
|
|
170
172
|
def starts=(time)
|
171
173
|
@starts = normalize_time(as_time(time)) || default_starts
|
@@ -179,6 +181,15 @@ module Montrose
|
|
179
181
|
@hour = map_arg(hours) { |h| assert_hour(h) }
|
180
182
|
end
|
181
183
|
|
184
|
+
def during=(during)
|
185
|
+
@during = case during
|
186
|
+
when Range
|
187
|
+
[decompose_during_arg(during)]
|
188
|
+
else
|
189
|
+
map_arg(during) { |d| decompose_during_arg(d) }
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
182
193
|
def day=(days)
|
183
194
|
@day = nested_map_arg(days) { |d| day_number!(d) }
|
184
195
|
end
|
@@ -200,7 +211,9 @@ module Montrose
|
|
200
211
|
end
|
201
212
|
|
202
213
|
def between=(range)
|
203
|
-
|
214
|
+
if Montrose.enable_deprecated_between_masking?
|
215
|
+
@covering = range
|
216
|
+
end
|
204
217
|
self[:starts] = range.first unless self[:starts]
|
205
218
|
self[:until] = range.last unless self[:until]
|
206
219
|
end
|
@@ -302,7 +315,7 @@ module Montrose
|
|
302
315
|
result[:mday] += map_mdays(v)
|
303
316
|
end
|
304
317
|
else
|
305
|
-
{
|
318
|
+
{day: map_days(arg)}
|
306
319
|
end
|
307
320
|
end
|
308
321
|
|
@@ -313,12 +326,12 @@ module Montrose
|
|
313
326
|
day = day_number(key)
|
314
327
|
return [:day, day] if day
|
315
328
|
|
316
|
-
|
329
|
+
raise ConfigurationError, "Did not recognize #{key} as a month or day"
|
317
330
|
end
|
318
331
|
|
319
332
|
def assert_range_includes(range, item, absolute = false)
|
320
333
|
test = absolute ? item.abs : item
|
321
|
-
|
334
|
+
raise ConfigurationError, "Out of range: #{range.inspect} does not include #{test}" unless range.include?(test)
|
322
335
|
|
323
336
|
item
|
324
337
|
end
|
@@ -333,18 +346,18 @@ module Montrose
|
|
333
346
|
def parse_frequency(input)
|
334
347
|
if input.respond_to?(:parts)
|
335
348
|
frequency, interval = duration_to_frequency_parts(input)
|
336
|
-
{
|
349
|
+
{every: frequency.to_s.singularize.to_sym, interval: interval}
|
337
350
|
elsif input.is_a?(Numeric)
|
338
351
|
frequency, interval = numeric_to_frequency_parts(input)
|
339
|
-
{
|
352
|
+
{every: frequency, interval: interval}
|
340
353
|
else
|
341
|
-
{
|
354
|
+
{every: Frequency.assert(input)}
|
342
355
|
end
|
343
356
|
end
|
344
357
|
|
345
358
|
def numeric_to_frequency_parts(number)
|
346
359
|
parts = nil
|
347
|
-
[
|
360
|
+
%i[year month week day hour minute].each do |freq|
|
348
361
|
div, mod = number.divmod(1.send(freq))
|
349
362
|
parts = [freq, div]
|
350
363
|
return parts if mod.zero?
|
@@ -355,5 +368,16 @@ module Montrose
|
|
355
368
|
def duration_to_frequency_parts(duration)
|
356
369
|
duration.parts.first
|
357
370
|
end
|
371
|
+
|
372
|
+
def decompose_during_arg(during)
|
373
|
+
case during
|
374
|
+
when Range
|
375
|
+
[decompose_during_arg(during.first), decompose_during_arg(during.last)]
|
376
|
+
when String
|
377
|
+
during.split(/[-—–]/).map { |d| as_time_parts(d) }
|
378
|
+
else
|
379
|
+
as_time_parts(during)
|
380
|
+
end
|
381
|
+
end
|
358
382
|
end
|
359
383
|
end
|
data/lib/montrose/recurrence.rb
CHANGED
@@ -238,7 +238,7 @@ module Montrose
|
|
238
238
|
else
|
239
239
|
fail SerializationError,
|
240
240
|
"Object was supposed to be a #{self}, but was a #{obj.class}. -- #{obj.inspect}"
|
241
|
-
|
241
|
+
end
|
242
242
|
|
243
243
|
JSON.dump(hash)
|
244
244
|
end
|
@@ -308,7 +308,7 @@ module Montrose
|
|
308
308
|
def to_hash
|
309
309
|
default_options.to_hash
|
310
310
|
end
|
311
|
-
|
311
|
+
alias_method :to_h, :to_hash
|
312
312
|
|
313
313
|
# Returns json string of options used to create the recurrence
|
314
314
|
#
|
@@ -346,20 +346,41 @@ module Montrose
|
|
346
346
|
def include?(timestamp)
|
347
347
|
return false if earlier?(timestamp) || later?(timestamp)
|
348
348
|
|
349
|
-
recurrence = finite? ? self :
|
349
|
+
recurrence = finite? ? self : fast_forward(timestamp)
|
350
350
|
|
351
351
|
recurrence.events.lazy.each do |event|
|
352
352
|
return true if event == timestamp
|
353
353
|
return false if event > timestamp
|
354
|
-
end
|
354
|
+
end || false
|
355
355
|
end
|
356
356
|
|
357
|
-
|
357
|
+
def fast_forward(timestamp)
|
358
|
+
return starts(timestamp) unless starts_at.present?
|
359
|
+
|
360
|
+
interval = default_options[:interval]
|
361
|
+
frequency = default_options[:every]
|
362
|
+
duration = interval.send(frequency)
|
363
|
+
|
364
|
+
# Calculate nearest earlier time ahead matching frequency * interval
|
365
|
+
jump = ((timestamp - starts_at) / duration).floor * duration
|
366
|
+
|
367
|
+
starts(starts_at + jump)
|
368
|
+
end
|
369
|
+
|
370
|
+
# Return true/false if recurrence will terminate
|
358
371
|
#
|
359
|
-
# @return [Boolean]
|
372
|
+
# @return [Boolean] returns true if recurrence has an end
|
360
373
|
#
|
361
374
|
def finite?
|
362
|
-
|
375
|
+
!infinite?
|
376
|
+
end
|
377
|
+
|
378
|
+
# Return true/false if recurrence will iterate infinitely
|
379
|
+
#
|
380
|
+
# @return [Boolean] returns true if recurrence has no end
|
381
|
+
#
|
382
|
+
def infinite?
|
383
|
+
!ends_at && !length
|
363
384
|
end
|
364
385
|
|
365
386
|
# Return true/false if given timestamp occurs before
|
@@ -391,7 +412,7 @@ module Montrose
|
|
391
412
|
loop do
|
392
413
|
stack.advance(clock.tick) do |time|
|
393
414
|
yielder << time
|
394
|
-
end
|
415
|
+
end || break
|
395
416
|
end
|
396
417
|
end
|
397
418
|
end
|
data/lib/montrose/rule.rb
CHANGED
@@ -36,10 +36,11 @@ module Montrose
|
|
36
36
|
end
|
37
37
|
|
38
38
|
require "montrose/rule/after"
|
39
|
-
require "montrose/rule/
|
39
|
+
require "montrose/rule/covering"
|
40
40
|
require "montrose/rule/day_of_month"
|
41
41
|
require "montrose/rule/day_of_week"
|
42
42
|
require "montrose/rule/day_of_year"
|
43
|
+
require "montrose/rule/during"
|
43
44
|
require "montrose/rule/except"
|
44
45
|
require "montrose/rule/hour_of_day"
|
45
46
|
require "montrose/rule/month_of_year"
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Montrose
|
4
|
+
module Rule
|
5
|
+
class Covering
|
6
|
+
include Montrose::Rule
|
7
|
+
|
8
|
+
def self.apply_options(opts)
|
9
|
+
opts[:covering].is_a?(Range) && opts[:covering]
|
10
|
+
end
|
11
|
+
|
12
|
+
# Initializes rule
|
13
|
+
#
|
14
|
+
# @param [Range] covering - timestamp range
|
15
|
+
#
|
16
|
+
def initialize(covering)
|
17
|
+
@covering = case covering.first
|
18
|
+
when Date
|
19
|
+
DateRange.new(covering)
|
20
|
+
else
|
21
|
+
covering
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def include?(time)
|
26
|
+
@covering.include?(time)
|
27
|
+
end
|
28
|
+
|
29
|
+
def continue?(time)
|
30
|
+
time < @covering.last
|
31
|
+
end
|
32
|
+
|
33
|
+
class DateRange < SimpleDelegator
|
34
|
+
def include?(time)
|
35
|
+
__getobj__.include?(time.to_date)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Montrose
|
4
|
+
module Rule
|
5
|
+
class During
|
6
|
+
include Montrose::Rule
|
7
|
+
|
8
|
+
def self.apply_options(opts)
|
9
|
+
opts[:during]
|
10
|
+
end
|
11
|
+
|
12
|
+
# Initializes rule
|
13
|
+
#
|
14
|
+
# @param during [Array<Array<Fixnum>>] array of time parts arrays, e.g. [[9, 0, 0], [17, 0, 0]], i.e., "9 to 5"
|
15
|
+
#
|
16
|
+
def initialize(during)
|
17
|
+
@during = during.map { |first, last| TimeOfDayRange.new(first, last) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def include?(time)
|
21
|
+
@during.any? { |range| range.include?(time) }
|
22
|
+
end
|
23
|
+
|
24
|
+
class TimeOfDay
|
25
|
+
def initialize(hour, min, sec)
|
26
|
+
@hour = hour
|
27
|
+
@min = min
|
28
|
+
@sec = sec
|
29
|
+
end
|
30
|
+
|
31
|
+
def seconds_since_midnight
|
32
|
+
@seconds_since_midnight ||= (@hour * 60 * 60) + (@min * 60) + @sec
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class TimeOfDayRange
|
37
|
+
def initialize(first, last, exclude_end: false)
|
38
|
+
@first = TimeOfDay.new(*first)
|
39
|
+
@last = TimeOfDay.new(*last)
|
40
|
+
@exclude_end = exclude_end
|
41
|
+
end
|
42
|
+
|
43
|
+
def include?(time)
|
44
|
+
range.include?(time.seconds_since_midnight.to_i)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def range
|
50
|
+
@range ||= Range.new(@first.seconds_since_midnight, @last.seconds_since_midnight, @exclude_end)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/montrose/rule/until.rb
CHANGED
data/lib/montrose/schedule.rb
CHANGED
@@ -8,27 +8,56 @@ module Montrose
|
|
8
8
|
# @attr_reader [Array] rules the list of recurrences
|
9
9
|
#
|
10
10
|
class Schedule
|
11
|
+
include Enumerable
|
12
|
+
|
11
13
|
attr_accessor :rules
|
12
14
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
15
|
+
class << self
|
16
|
+
# Instantiates a schedule and yields the instance to an optional
|
17
|
+
# block for building recurrences inline
|
18
|
+
#
|
19
|
+
# @example Build a schedule with multiple rules added in the given block
|
20
|
+
# schedule = Montrose::Schedule.build do |s|
|
21
|
+
# s << { every: :day }
|
22
|
+
# s << { every: :year }
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# @return [Montrose::Schedule]
|
26
|
+
#
|
27
|
+
def build
|
28
|
+
schedule = new
|
29
|
+
yield schedule if block_given?
|
30
|
+
schedule
|
31
|
+
end
|
32
|
+
|
33
|
+
def dump(obj)
|
34
|
+
return nil if obj.nil?
|
35
|
+
return dump(load(obj)) if obj.is_a?(String)
|
36
|
+
|
37
|
+
array = case obj
|
38
|
+
when Array
|
39
|
+
new(obj).to_a
|
40
|
+
when self
|
41
|
+
obj.to_a
|
42
|
+
else
|
43
|
+
fail SerializationError,
|
44
|
+
"Object was supposed to be a #{self}, but was a #{obj.class}. -- #{obj.inspect}"
|
45
|
+
end
|
46
|
+
|
47
|
+
JSON.dump(array)
|
48
|
+
end
|
49
|
+
|
50
|
+
def load(json)
|
51
|
+
return nil if json.blank?
|
52
|
+
|
53
|
+
new JSON.parse(json)
|
54
|
+
rescue JSON::ParserError => e
|
55
|
+
fail SerializationError, "Could not parse JSON: #{e}"
|
56
|
+
end
|
28
57
|
end
|
29
58
|
|
30
|
-
def initialize
|
31
|
-
@rules =
|
59
|
+
def initialize(rules = [])
|
60
|
+
@rules = rules.map { |rule| Montrose::Recurrence.new(rule) }
|
32
61
|
end
|
33
62
|
|
34
63
|
# Add a recurrence rule to the schedule, either by hash or recurrence
|
@@ -48,7 +77,7 @@ module Montrose
|
|
48
77
|
|
49
78
|
self
|
50
79
|
end
|
51
|
-
|
80
|
+
alias_method :add, :<<
|
52
81
|
|
53
82
|
# Return true/false if given timestamp is included in any of the rules
|
54
83
|
# found in the schedule
|
@@ -59,6 +88,31 @@ module Montrose
|
|
59
88
|
@rules.any? { |r| r.include?(timestamp) }
|
60
89
|
end
|
61
90
|
|
91
|
+
# Iterate over the events of a recurrence. Along with the Enumerable
|
92
|
+
# module, this makes Montrose occurrences enumerable like other Ruby
|
93
|
+
# collections
|
94
|
+
#
|
95
|
+
# @example Iterate over a finite recurrence
|
96
|
+
# schedule = Montrose::Schedule.build do |s|
|
97
|
+
# s << { every: :day }
|
98
|
+
# end
|
99
|
+
# schedule.each do |event|
|
100
|
+
# puts event
|
101
|
+
# end
|
102
|
+
#
|
103
|
+
# @example Iterate over an infinite recurrence
|
104
|
+
# schedule = Montrose::Schedule.build do |s|
|
105
|
+
# s << { every: :day }
|
106
|
+
# end
|
107
|
+
# schedule.lazy.each do |event|
|
108
|
+
# puts event
|
109
|
+
# end
|
110
|
+
#
|
111
|
+
# @return [Enumerator] an enumerator of recurrence timestamps
|
112
|
+
def each(&block)
|
113
|
+
events.each(&block)
|
114
|
+
end
|
115
|
+
|
62
116
|
# Returns an enumerator for iterating over timestamps in the schedule
|
63
117
|
#
|
64
118
|
# @example Return the events
|
@@ -73,21 +127,55 @@ module Montrose
|
|
73
127
|
enums = @rules.map { |r| r.merge(opts).events }
|
74
128
|
Enumerator.new do |y|
|
75
129
|
loop do
|
76
|
-
enum = active_enums(enums).min_by(&:peek)
|
130
|
+
(enum = active_enums(enums).min_by(&:peek)) || break
|
77
131
|
y << enum.next
|
78
132
|
end
|
79
133
|
end
|
80
134
|
end
|
81
135
|
|
136
|
+
# Returns an array of the options used to create the recurrence
|
137
|
+
#
|
138
|
+
# @return [Array] array of hashes of recurrence options
|
139
|
+
#
|
140
|
+
def to_a
|
141
|
+
@rules.map(&:to_hash)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Returns json string of options used to create the schedule
|
145
|
+
#
|
146
|
+
# @return [String] json of schedule recurrences
|
147
|
+
#
|
148
|
+
def to_json(*args)
|
149
|
+
JSON.dump(to_a, *args)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Returns json array of options used to create the schedule
|
153
|
+
#
|
154
|
+
# @return [Array] json of schedule recurrence options
|
155
|
+
#
|
156
|
+
def as_json(*args)
|
157
|
+
to_a.as_json(*args)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Returns options used to create the schedule recurrences in YAML format
|
161
|
+
#
|
162
|
+
# @return [String] YAML-formatted schedule recurrence options
|
163
|
+
#
|
164
|
+
def to_yaml(*args)
|
165
|
+
YAML.dump(JSON.parse(to_json(*args)))
|
166
|
+
end
|
167
|
+
|
168
|
+
def inspect
|
169
|
+
"#<#{self.class}:#{object_id.to_s(16)} #{to_a.inspect}>"
|
170
|
+
end
|
171
|
+
|
82
172
|
private
|
83
173
|
|
84
174
|
def active_enums(enums)
|
85
175
|
enums.select do |e|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
false
|
90
|
-
end
|
176
|
+
e.peek
|
177
|
+
rescue StopIteration
|
178
|
+
false
|
91
179
|
end
|
92
180
|
end
|
93
181
|
end
|