montrose 0.10.0 → 0.12.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.
- 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
|