cyclical 0.1.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.
@@ -0,0 +1,235 @@
1
+ require 'cyclical/filters/months_filter'
2
+ require 'cyclical/filters/weekdays_filter'
3
+ require 'cyclical/filters/monthdays_filter'
4
+ require 'cyclical/filters/yeardays_filter'
5
+
6
+ module Cyclical
7
+
8
+ # Rules describe the basic recurrence patterns (frequency and interval) and hold the set of rules (called filters)
9
+ # that a candidate date must match to be included into the recurrence set.
10
+ # Rules can align a date to a closest date (in the past or in the future) matching all the filters with respect to
11
+ # selected start date of the recurrence.
12
+ class Rule
13
+
14
+ attr_reader :interval
15
+
16
+ def initialize(interval = 1)
17
+ @interval = interval
18
+ @filters = []
19
+ @filter_map = {}
20
+ end
21
+
22
+ # rule specification DSL
23
+
24
+ def count(n = nil)
25
+ return @count unless n
26
+
27
+ @count = n
28
+ self
29
+ end
30
+
31
+ def stop(t = nil)
32
+ return @stop unless t
33
+
34
+ @stop = t
35
+ self
36
+ end
37
+
38
+ def months(*months)
39
+ raise RuntimeError, "Months filter already set" if @filter_map[:month]
40
+
41
+ f = MonthsFilter.new(*months)
42
+ @filters << f
43
+ @filter_map[:months] = f
44
+
45
+ self
46
+ end
47
+ alias :month :months
48
+
49
+ def weekdays(*weekdays)
50
+ raise RuntimeError, "weekdays filter already set" if @filter_map[:weekdays]
51
+ weekdays = [self] + weekdays
52
+
53
+ f = WeekdaysFilter.new(*weekdays)
54
+ @filters << f
55
+ @filter_map[:weekdays] = f
56
+
57
+ self
58
+ end
59
+ alias :weekday :weekdays
60
+
61
+ def monthdays(*monthdays)
62
+ raise RuntimeError, "monthdays filter already set" if @filter_map[:monthdays]
63
+
64
+ f = MonthdaysFilter.new(*monthdays)
65
+ @filters << f
66
+ @filter_map[:monthdays] = f
67
+
68
+ self
69
+ end
70
+ alias :monthday :monthdays
71
+
72
+ def yeardays(*yeardays)
73
+ raise RuntimeError, "yeardays filter already set" if @filter_map[:yeardays]
74
+
75
+ f = YeardaysFilter.new(*yeardays)
76
+ @filters << f
77
+ @filter_map[:yeardays] = f
78
+
79
+ self
80
+ end
81
+ alias :yearday :yeardays
82
+
83
+
84
+ def filters(kind = nil)
85
+ return @filters if kind.nil?
86
+
87
+ @filter_map[kind.to_sym]
88
+ end
89
+
90
+ # rule API
91
+
92
+ def finite?
93
+ !infinite?
94
+ end
95
+
96
+ def infinite?
97
+ @count.nil? && @stop.nil?
98
+ end
99
+
100
+ # returns true if time is aligned to the recurrence pattern and matches all the filters
101
+ def match?(time, base)
102
+ aligned?(time, base) && @filters.all? { |f| f.match?(time) }
103
+ end
104
+
105
+ # get next date matching the rule (not checking limits). Returns next occurrence even if +time+ matches the rule.
106
+ def next(time, base)
107
+ current = time
108
+ until match?(current, base) && current > time
109
+ pot_next = align(potential_next(current, base), base)
110
+ pot_next += min_step if pot_next == current
111
+
112
+ current = pot_next
113
+ end
114
+
115
+ current
116
+ end
117
+
118
+ # get previous date matching the rule (not checking limits). Returns next occurrence even if +time+ matches the rule.
119
+ def previous(time, base)
120
+ current = time
121
+ until match?(current, base) && current < time
122
+ pot_prev = align(potential_previous(current, base), base)
123
+ pot_prev -= min_step if pot_prev == current
124
+
125
+ current = pot_prev
126
+ end
127
+
128
+ current
129
+ end
130
+
131
+ # basic building blocks of the computations
132
+
133
+ def aligned?(time, base)
134
+ # for subclass to override
135
+ end
136
+
137
+ def step
138
+ # for subclass to override
139
+ end
140
+
141
+ def to_hash
142
+ hash = { :freq => self.class.to_s.underscore.split('/').last.split('_').first, :interval => @interval }
143
+
144
+ hash[:count] = @count if @count
145
+ hash[:stop] = @stop if @stop
146
+
147
+ hash[:weekdays] = (filters(:weekdays).weekdays + [filters(:weekdays).ordered_weekdays]) if filters(:weekdays)
148
+ hash[:monthdays] = filters(:monthdays).monthdays if filters(:monthdays)
149
+ hash[:yeardays] = filters(:yeardays).yeardays if filters(:yeardays)
150
+ hash[:months] = filters(:months).months if filters(:months)
151
+
152
+ hash
153
+ end
154
+
155
+ def to_json
156
+ to_hash.to_json
157
+ end
158
+
159
+ # factory methods
160
+
161
+ class << self
162
+ def daily(interval = 1)
163
+ DailyRule.new(interval)
164
+ end
165
+
166
+ def yearly(interval = 1)
167
+ YearlyRule.new(interval)
168
+ end
169
+
170
+ def weekly(interval = 1)
171
+ WeeklyRule.new(interval)
172
+ end
173
+
174
+ def monthly(interval = 1)
175
+ MonthlyRule.new(interval)
176
+ end
177
+
178
+ def from_hash(hash)
179
+ raise "Bad Hash format: '#{hash.inspect}'" unless hash[:freq] && hash[:interval]
180
+
181
+ rule = self.send(hash[:freq].to_sym, hash[:interval].to_i)
182
+
183
+ rule.count(hash[:count]) if hash.has_key?(:count)
184
+ rule.stop(hash[:stop]) if hash.has_key?(:stop)
185
+
186
+ rule.weekdays(*hash[:weekdays]) if hash.has_key?(:weekdays)
187
+ rule.monthdays(*hash[:monthdays]) if hash.has_key?(:monthdays)
188
+ rule.yeardays(*hash[:yeardays]) if hash.has_key?(:yeardays)
189
+ rule.months(*hash[:months]) if hash.has_key?(:months)
190
+
191
+ rule
192
+ end
193
+
194
+ def from_json(json)
195
+ h = JSON.parse(json)
196
+ h['stop'] = Time.parse(h['stop']) if h['stop']
197
+
198
+ from_hash(h.symbolize_keys)
199
+ end
200
+ end
201
+
202
+ protected
203
+
204
+ # Next comes the heart of all the calculations
205
+
206
+ # Find a potential next date matching the rule as a maximum of next
207
+ # valid dates from all the filters. Subclasses should add a check of
208
+ # recurrence pattern match
209
+ def potential_next(current, base)
210
+ @filters.map { |f| f.next(current) }.max || current
211
+ end
212
+
213
+ # Find a potential previous date matching the rule as a minimum of previous
214
+ # valid dates from all the filters. Subclasses should add a check of
215
+ # recurrence pattern match
216
+ def potential_previous(current, base)
217
+ @filters.map { |f| f.previous(current) }.min || current
218
+ end
219
+
220
+ # Should return a time aligned to the base in the rule interval resolution, e.g.:
221
+ # - in a daily rule a time on the same day with a correct hour, minute and second
222
+ # - in a weekly rule a time in the same week with a correct weekday, hour, minute and second
223
+ def align(time, base)
224
+ raise NotImplementedError, "#{self.class}.align should be overriden and return a time in the period of time parameter, aligned to base"
225
+ end
226
+
227
+ # Minimal step of all the filters and the recurrence rule. This allows the
228
+ # next/previous calculation to move a sane amount of time forward when all
229
+ # the filters and the rule match but the candidate is before/after the
230
+ # requested time (which is caused by date alignment)
231
+ def min_step
232
+ @min_step ||= ([step] + @filters.map { |f| f.step }).min
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,49 @@
1
+ require 'cyclical/rule'
2
+
3
+ module Cyclical
4
+ # holds daily rule configuration
5
+ class DailyRule < Rule
6
+
7
+ def aligned?(time, base)
8
+ return false unless (base.to_date - time.to_date) % @interval == 0
9
+ return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec]
10
+
11
+ true
12
+ end
13
+
14
+ def step
15
+ @interval.days
16
+ end
17
+
18
+ protected
19
+
20
+ def potential_next(current, base)
21
+ candidate = super(current, base)
22
+
23
+ rem = (base.to_date - candidate.to_date) % @interval
24
+
25
+ return candidate if rem == 0
26
+
27
+ rem += @interval if rem < 0
28
+ candidate.beginning_of_day + rem.days
29
+ end
30
+
31
+ def potential_previous(current, base)
32
+ candidate = super(current, base)
33
+
34
+ rem = (base.to_date - candidate.to_date) % @interval
35
+
36
+ return candidate if rem == 0
37
+
38
+ rem += @interval if rem < 0
39
+ candidate.beginning_of_day + (rem - @interval).days
40
+ end
41
+
42
+ def align(time, base)
43
+ # compensate crossing DST barrier (oh my...)
44
+ offset = time.beginning_of_day.utc_offset
45
+ time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
46
+ time += (offset - time.utc_offset)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ require 'cyclical/rule'
2
+
3
+ module Cyclical
4
+ # holds weekly rule configuration
5
+ class MonthlyRule < Rule
6
+
7
+ # check if time is aligned to a base time, including interval check
8
+ def aligned?(time, base)
9
+ return false unless ((12 * base.year + base.mon) - (12 * time.year + time.mon)) % @interval == 0
10
+ return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
11
+ return false unless base.day == time.day || monthday_filters
12
+
13
+ true
14
+ end
15
+
16
+ # default step of the rule
17
+ def step
18
+ @interval.months
19
+ end
20
+
21
+ protected
22
+
23
+ def potential_next(current, base)
24
+ candidate = super(current, base)
25
+
26
+ rem = ((12 * base.year + base.mon) - (12 * candidate.year + candidate.mon)) % @interval
27
+ return candidate if rem == 0
28
+
29
+ (candidate + rem.months).beginning_of_month
30
+ end
31
+
32
+ def potential_previous(current, base)
33
+ candidate = super(current, base)
34
+
35
+ rem = ((12 * base.year + base.mon) - (12 * candidate.year + candidate.mon)) % @interval
36
+ return candidate if rem == 0
37
+
38
+ (candidate + rem.months - step).end_of_month
39
+ end
40
+
41
+ def align(time, base)
42
+ time = time.beginning_of_month + (base.day - 1).days unless time.day == base.day || monthday_filters
43
+
44
+ # compensate crossing DST barrier (oh my...)
45
+ offset = time.beginning_of_day.utc_offset
46
+ time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
47
+ time += (offset - time.utc_offset)
48
+
49
+ time
50
+ end
51
+
52
+ def monthday_filters
53
+ filters(:weekdays) || filters(:monthdays) || filters(:yeardays) || filters(:weeks) || filters(:months)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,58 @@
1
+ require 'cyclical/rule'
2
+
3
+ module Cyclical
4
+ # holds daily rule configuration
5
+ class WeeklyRule < Rule
6
+
7
+ # check if time is aligned to a base time, including interval check
8
+ def aligned?(time, base)
9
+ return false unless ((base.beginning_of_week - time.beginning_of_week) / 604800).to_i % @interval == 0 # 604800 = 7.days
10
+ return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
11
+
12
+ return false unless base.wday == time.wday || weekday_filters
13
+
14
+ # wow, passed every test
15
+ true
16
+ end
17
+
18
+ # default step of the rule
19
+ def step
20
+ @interval.weeks
21
+ end
22
+
23
+ protected
24
+
25
+ def potential_next(current, base)
26
+ candidate = super(current, base)
27
+
28
+ rem = ((base.beginning_of_week - candidate.beginning_of_week) / 604800).to_i % @interval
29
+ return candidate if rem == 0
30
+
31
+ (candidate + rem.weeks).beginning_of_week
32
+ end
33
+
34
+ def potential_previous(current, base)
35
+ candidate = super(current, base)
36
+
37
+ rem = ((base.beginning_of_week - candidate.beginning_of_week) / 604800).to_i % @interval
38
+ return candidate if rem == 0
39
+
40
+ (candidate + rem.weeks - step).end_of_week
41
+ end
42
+
43
+ def align(time, base)
44
+ time = time.beginning_of_week + base.wday.days unless time.wday == base.wday || weekday_filters
45
+
46
+ # compensate crossing DST barrier (oh my...)
47
+ offset = time.beginning_of_day.utc_offset
48
+ time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
49
+ time += (offset - time.utc_offset)
50
+
51
+ time
52
+ end
53
+
54
+ def weekday_filters
55
+ filters(:weekdays) || filters(:monthdays) || filters(:yeardays) || filters(:yeardays) || filters(:weeks) || filters(:months)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,66 @@
1
+ require 'cyclical/rule'
2
+
3
+ module Cyclical
4
+ # holds daily rule configuration
5
+ class YearlyRule < Rule
6
+
7
+ # check if time is aligned to a base time, including interval check
8
+ def aligned?(time, base)
9
+ return false unless (base.year - time.year).to_i % @interval == 0
10
+ return false unless [time.hour, time.min, time.sec] == [base.hour, base.min, base.sec] # the shortest filter we support is for days
11
+ return false unless time.day == base.day || filters(:weekdays) || filters(:monthdays) || filters(:yeardays)
12
+ return false unless time.month == base.month || filters(:yeardays) || filters(:weeks) || filters(:months)
13
+
14
+ # wow, passed every test
15
+ true
16
+ end
17
+
18
+ # default step of the rule
19
+ def step
20
+ @interval.years
21
+ end
22
+
23
+ private
24
+
25
+ # closest valid date
26
+ def potential_next(current, base)
27
+ candidate = super(current, base)
28
+ return candidate if (base.year - candidate.year).to_i % @interval == 0
29
+
30
+ years = ((base.year - candidate.year).to_i % @interval)
31
+
32
+ (candidate + years.years).beginning_of_year
33
+ end
34
+
35
+ def potential_previous(current, base)
36
+ candidate = super(current, base)
37
+ return candidate if (base.year - candidate.year).to_i % @interval == 0
38
+
39
+ years = ((base.year - candidate.year).to_i % @interval)
40
+
41
+ (candidate + (years - @interval).years).end_of_year
42
+ end
43
+
44
+ def align(time, base)
45
+ day = (day_filters ? time.day : base.day)
46
+ mon = (month_filters ? time.mon : base.mon)
47
+
48
+ time = time.beginning_of_year + (mon - 1).months + (day - 1).days
49
+
50
+ # compensate crossing DST barrier (oh my...)
51
+ offset = time.beginning_of_day.utc_offset
52
+ time = time.beginning_of_day + base.hour.hours + base.min.minutes + base.sec.seconds
53
+ time += (offset - time.utc_offset)
54
+
55
+ time
56
+ end
57
+
58
+ def day_filters
59
+ filters(:weekdays) || filters(:monthdays) || filters(:yeardays)
60
+ end
61
+
62
+ def month_filters
63
+ filters(:weekdays) || filters(:yeardays) || filters(:weeks) || filters(:months)
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,125 @@
1
+ require 'time'
2
+
3
+ require 'cyclical/rule'
4
+
5
+ require 'cyclical/rules/daily_rule'
6
+ require 'cyclical/rules/weekly_rule'
7
+ require 'cyclical/rules/monthly_rule'
8
+ require 'cyclical/rules/yearly_rule'
9
+
10
+ require 'cyclical/occurrence'
11
+
12
+ module Cyclical
13
+ class Schedule
14
+
15
+ attr_reader :start_time
16
+ attr_accessor :end_time
17
+
18
+ def initialize(start_time, rule = nil)
19
+ @occurrence = Occurrence.new rule, start_time unless rule.nil?
20
+ @start_time = @occurrence ? @occurrence.start_time : start_time
21
+ end
22
+
23
+ def rule=(rule)
24
+ @occurrence = (rule.nil? ? nil : Occurrence.new(rule, start_time))
25
+ @occurrence.duration = end_time ? (end_time - start_time) : 0
26
+ end
27
+
28
+ def rule
29
+ @occurrence.nil? ? nil : @occurrence.rule
30
+ end
31
+
32
+ def end_time=(time)
33
+ raise "End time is before start time" if time < @start_time
34
+ @end_time = time
35
+ @occurrence.duration = (time - start_time) unless @occurrence.nil?
36
+
37
+ time
38
+ end
39
+
40
+ # query interface
41
+
42
+ def first(n)
43
+ return [start_time] if @occurrence.nil?
44
+
45
+ @occurrence.next_occurrences(n, start_time)
46
+ end
47
+
48
+ # first occurrence in [time, infinity)
49
+ def next_occurrence(time)
50
+ return (start_time < time ? nil : start_time) if @occurrence.nil?
51
+
52
+ @occurrence.next_occurrence(time)
53
+ end
54
+
55
+ # last occurrence in (-infinity, time)
56
+ def previous_occurrence(time)
57
+ return (start_time >= time ? nil : start_time) if @occurrence.nil?
58
+
59
+ @occurrence.previous_occurrence(time)
60
+ end
61
+
62
+ def occurrences(end_time = nil)
63
+ raise ArgumentError, "You have to specify end time for an infinite schedule occurrence listing" if end_time.nil? && @occurrence && @occurrence.rule.infinite?
64
+
65
+ if end_time
66
+ occurrences_between(start_time, end_time)
67
+ else
68
+ return [start_time] if @occurrence.nil?
69
+
70
+ @occurrence.all
71
+ end
72
+ end
73
+
74
+ # occurrences in [t1, t2)
75
+ def occurrences_between(t1, t2)
76
+ return ((start_time < t1 || @start_time >= t2) ? [] : [start_time]) if @occurrence.nil?
77
+
78
+ @occurrence.occurrences_between(t1, t2)
79
+ end
80
+
81
+ def suboccurrences_between(t1, t2)
82
+ raise RuntimeError, "Schedule must have an end time to compute suboccurrences" unless end_time
83
+
84
+ return [Suboccurrence.find(:occurrence => start_time..end_time, :interval => t1..t2)] if @occurrence.nil?
85
+
86
+ @occurrence.suboccurrences_between(t1, t2)
87
+ end
88
+
89
+ def to_hash
90
+ hash = @occurrence.nil? ? {} : @occurrence.to_hash.clone
91
+
92
+ hash[:start] = start_time
93
+ hash[:end] = end_time if end_time
94
+
95
+ hash
96
+ end
97
+
98
+ def to_json
99
+ to_hash.to_json
100
+ end
101
+
102
+ def self.from_hash(hash)
103
+ rule = hash.clone
104
+ start_time = hash.delete(:start)
105
+ end_time = hash.delete(:end)
106
+
107
+ rule = hash[:freq] && hash[:interval] ? Rule.from_hash(hash) : nil
108
+
109
+ s = Schedule.new start_time, rule
110
+ s.end_time = end_time
111
+
112
+ s
113
+ end
114
+
115
+ def self.from_json(json)
116
+ h = JSON.parse(json)
117
+
118
+ h['start'] = Time.parse(h['start']) if h['start']
119
+ h['end'] = Time.parse(h['end']) if h['end']
120
+ h['stop'] = Time.parse(h['stop']) if h['stop']
121
+
122
+ from_hash(h.symbolize_keys)
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,48 @@
1
+ module Cyclical
2
+ # Holds suboccurrence of a schedule, i.e. time interval which is a subinterval of a single occurrence.
3
+ # This is used to find actual time spans to display in a given time interval (for example in a calendar)
4
+ class Suboccurrence
5
+ attr_reader :start, :end, :occurrence_start, :occurrence_end
6
+
7
+ alias :occurrence_start? :occurrence_start
8
+ alias :occurrence_end? :occurrence_end
9
+
10
+ # factory method for finding suboccurrence of a single occurrence with an interval, with the ability to return nil
11
+ # This might be a totally bad idea, I'm not sure right now really...
12
+ def self.find(attrs)
13
+ raise ArgumentError, "Missing occurrence" unless (occurrence = attrs[:occurrence]).is_a?(Range)
14
+ raise ArgumentError, "Missing interval" unless (interval = attrs[:interval]).is_a?(Range)
15
+
16
+ return nil if occurrence.last <= interval.first || occurrence.first >= interval.last
17
+
18
+ suboccurrence = {}
19
+
20
+ if occurrence.first < interval.first
21
+ suboccurrence[:start] = interval.first
22
+ suboccurrence[:occurrence_start] = false
23
+ else
24
+ suboccurrence[:start] = occurrence.first
25
+ suboccurrence[:occurrence_start] = true
26
+ end
27
+
28
+ if occurrence.last > interval.last
29
+ suboccurrence[:end] = interval.last
30
+ suboccurrence[:occurrence_end] = false
31
+ else
32
+ suboccurrence[:end] = occurrence.last
33
+ suboccurrence[:occurrence_end] = true
34
+ end
35
+
36
+ return new(suboccurrence)
37
+ end
38
+
39
+ private
40
+
41
+ def initialize(attrs)
42
+ @start = attrs[:start]
43
+ @end = attrs[:end]
44
+ @occurrence_start = attrs[:occurrence_start]
45
+ @occurrence_end = attrs[:occurrence_end]
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module Cyclical
2
+ VERSION = "0.1.0"
3
+ end
data/lib/cyclical.rb ADDED
@@ -0,0 +1,12 @@
1
+ require 'active_support/core_ext/time/acts_like'
2
+ require 'active_support/core_ext/time/calculations'
3
+ require 'active_support/core_ext/integer/time'
4
+ require 'active_support/core_ext/numeric/time'
5
+
6
+ require 'json'
7
+
8
+ require "cyclical/version"
9
+ require "cyclical/schedule"
10
+
11
+ module Cyclical
12
+ end
@@ -0,0 +1,36 @@
1
+ require 'spec_helper'
2
+
3
+ describe do
4
+ before do
5
+ @filter = MonthdaysFilter.new(1, 3, -5)
6
+ end
7
+
8
+ it "should accept weekdays as constructor arguments" do
9
+ @filter.monthdays.should == [-5, 1, 3]
10
+ end
11
+
12
+
13
+ it "should match only the selected monthdays" do
14
+ filter = MonthdaysFilter.new(1, 3, 17, -6)
15
+
16
+ filter.match?(Time.utc(2000, 1, 1)).should be_true
17
+ filter.match?(Time.utc(2000, 1, 3)).should be_true
18
+ filter.match?(Time.utc(2000, 1, 17)).should be_true
19
+ filter.match?(Time.utc(2000, 1, 26)).should be_true
20
+
21
+ filter.match?(Time.utc(2000, 2, 26)).should be_false
22
+
23
+ filter.match?(Time.utc(2000, 1, 2)).should be_false
24
+ filter.match?(Time.utc(2000, 1, 4)).should be_false
25
+ filter.match?(Time.utc(2000, 1, 5)).should be_false
26
+ filter.match?(Time.utc(2000, 1, 6)).should be_false
27
+ filter.match?(Time.utc(2000, 1, 8)).should be_false
28
+ filter.match?(Time.utc(2000, 1, 27)).should be_false
29
+ end
30
+
31
+ it "should provide next valid date" do
32
+ @filter.next(Time.utc(2010, 1, 1, 21, 13, 11)).should == Time.utc(2010, 1, 1, 21, 13, 11)
33
+ @filter.next(Time.utc(2010, 1, 2, 21, 13, 11)).should == Time.utc(2010, 1, 3, 21, 13, 11)
34
+ @filter.next(Time.utc(2010, 1, 18, 21, 13, 11)).should == Time.utc(2010, 1, 27, 21, 13, 11)
35
+ end
36
+ end