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.
Files changed (42) hide show
  1. data/lib/ice_cube.rb +80 -0
  2. data/lib/ice_cube/builders/hash_builder.rb +27 -0
  3. data/lib/ice_cube/builders/ical_builder.rb +59 -0
  4. data/lib/ice_cube/builders/string_builder.rb +74 -0
  5. data/lib/ice_cube/deprecated.rb +28 -0
  6. data/lib/ice_cube/errors/count_exceeded.rb +7 -0
  7. data/lib/ice_cube/errors/until_exceeded.rb +7 -0
  8. data/lib/ice_cube/errors/zero_interval.rb +7 -0
  9. data/lib/ice_cube/rule.rb +182 -0
  10. data/lib/ice_cube/rules/daily_rule.rb +14 -0
  11. data/lib/ice_cube/rules/hourly_rule.rb +14 -0
  12. data/lib/ice_cube/rules/minutely_rule.rb +14 -0
  13. data/lib/ice_cube/rules/monthly_rule.rb +14 -0
  14. data/lib/ice_cube/rules/secondly_rule.rb +13 -0
  15. data/lib/ice_cube/rules/weekly_rule.rb +14 -0
  16. data/lib/ice_cube/rules/yearly_rule.rb +14 -0
  17. data/lib/ice_cube/schedule.rb +414 -0
  18. data/lib/ice_cube/single_occurrence_rule.rb +28 -0
  19. data/lib/ice_cube/time_util.rb +250 -0
  20. data/lib/ice_cube/validated_rule.rb +108 -0
  21. data/lib/ice_cube/validations/count.rb +56 -0
  22. data/lib/ice_cube/validations/daily_interval.rb +55 -0
  23. data/lib/ice_cube/validations/day.rb +65 -0
  24. data/lib/ice_cube/validations/day_of_month.rb +52 -0
  25. data/lib/ice_cube/validations/day_of_week.rb +70 -0
  26. data/lib/ice_cube/validations/day_of_year.rb +55 -0
  27. data/lib/ice_cube/validations/hour_of_day.rb +52 -0
  28. data/lib/ice_cube/validations/hourly_interval.rb +57 -0
  29. data/lib/ice_cube/validations/lock.rb +47 -0
  30. data/lib/ice_cube/validations/minute_of_hour.rb +51 -0
  31. data/lib/ice_cube/validations/minutely_interval.rb +57 -0
  32. data/lib/ice_cube/validations/month_of_year.rb +49 -0
  33. data/lib/ice_cube/validations/monthly_interval.rb +51 -0
  34. data/lib/ice_cube/validations/schedule_lock.rb +41 -0
  35. data/lib/ice_cube/validations/second_of_minute.rb +49 -0
  36. data/lib/ice_cube/validations/secondly_interval.rb +54 -0
  37. data/lib/ice_cube/validations/until.rb +51 -0
  38. data/lib/ice_cube/validations/weekly_interval.rb +60 -0
  39. data/lib/ice_cube/validations/yearly_interval.rb +49 -0
  40. data/lib/ice_cube/version.rb +5 -0
  41. data/spec/spec_helper.rb +11 -0
  42. 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