ice_cube 0.6.14 → 0.7.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 (43) hide show
  1. data/lib/ice_cube.rb +63 -37
  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/errors/count_exceeded.rb +7 -0
  6. data/lib/ice_cube/errors/until_exceeded.rb +7 -0
  7. data/lib/ice_cube/rule.rb +85 -147
  8. data/lib/ice_cube/rules/daily_rule.rb +5 -27
  9. data/lib/ice_cube/rules/hourly_rule.rb +6 -26
  10. data/lib/ice_cube/rules/minutely_rule.rb +5 -25
  11. data/lib/ice_cube/rules/monthly_rule.rb +6 -30
  12. data/lib/ice_cube/rules/secondly_rule.rb +5 -26
  13. data/lib/ice_cube/rules/weekly_rule.rb +5 -36
  14. data/lib/ice_cube/rules/yearly_rule.rb +8 -34
  15. data/lib/ice_cube/schedule.rb +257 -229
  16. data/lib/ice_cube/single_occurrence_rule.rb +28 -0
  17. data/lib/ice_cube/time_util.rb +202 -76
  18. data/lib/ice_cube/validated_rule.rb +107 -0
  19. data/lib/ice_cube/validations/count.rb +56 -0
  20. data/lib/ice_cube/validations/daily_interval.rb +51 -0
  21. data/lib/ice_cube/validations/day.rb +45 -31
  22. data/lib/ice_cube/validations/day_of_month.rb +44 -44
  23. data/lib/ice_cube/validations/day_of_week.rb +60 -47
  24. data/lib/ice_cube/validations/day_of_year.rb +48 -44
  25. data/lib/ice_cube/validations/hour_of_day.rb +42 -30
  26. data/lib/ice_cube/validations/hourly_interval.rb +50 -0
  27. data/lib/ice_cube/validations/lock.rb +47 -0
  28. data/lib/ice_cube/validations/minute_of_hour.rb +42 -31
  29. data/lib/ice_cube/validations/minutely_interval.rb +50 -0
  30. data/lib/ice_cube/validations/month_of_year.rb +39 -30
  31. data/lib/ice_cube/validations/monthly_interval.rb +47 -0
  32. data/lib/ice_cube/validations/schedule_lock.rb +41 -0
  33. data/lib/ice_cube/validations/second_of_minute.rb +39 -30
  34. data/lib/ice_cube/validations/secondly_interval.rb +50 -0
  35. data/lib/ice_cube/validations/until.rb +49 -0
  36. data/lib/ice_cube/validations/weekly_interval.rb +50 -0
  37. data/lib/ice_cube/validations/yearly_interval.rb +45 -0
  38. data/lib/ice_cube/version.rb +2 -2
  39. data/spec/spec_helper.rb +13 -0
  40. metadata +50 -9
  41. data/lib/ice_cube/rule_occurrence.rb +0 -94
  42. data/lib/ice_cube/validation.rb +0 -44
  43. data/lib/ice_cube/validation_types.rb +0 -137
@@ -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
@@ -1,101 +1,227 @@
1
+ require 'date'
2
+
1
3
  module IceCube
2
4
 
3
5
  module TimeUtil
4
-
5
- LeapYearMonthDays = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
6
- CommonYearMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
7
-
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
+ MONTHS = {
16
+ :january => 1, :february => 2, :march => 3, :april => 4, :may => 5,
17
+ :june => 6, :july => 7, :august => 8, :september => 9, :october => 10,
18
+ :november => 11, :december => 12
19
+ }
20
+
21
+ # Serialize a time appropriate for storing
8
22
  def self.serialize_time(time)
9
- if time.is_a?(ActiveSupport::TimeWithZone)
10
- { :time => time, :zone => time.time_zone.name }
23
+ if defined?(:ActiveSupport) && const_defined?(:ActiveSupport) && time.is_a?(ActiveSupport::TimeWithZone)
24
+ { :time => time.utc, :zone => time.time_zone.name }
11
25
  elsif time.is_a?(Time)
12
26
  time
13
27
  end
14
28
  end
15
-
16
- def self.deserialize_time(time_or_hash)
17
- return time_or_hash if time_or_hash.is_a?(Time) # for backward-compat
18
- if time_or_hash.is_a?(Hash)
29
+
30
+ # Deserialize a time serialized with serialize_time
31
+ def self.deserialize_time(time_or_hash)
32
+ if time_or_hash.is_a?(Time)
33
+ time_or_hash
34
+ elsif time_or_hash.is_a?(Hash)
19
35
  time_or_hash[:time].in_time_zone(time_or_hash[:zone])
20
36
  end
21
37
  end
22
38
 
23
- # TODO can we improve this more?
24
- def self.date_in_n_months(date, month_distance)
25
-
26
- next_mark = date
27
- days_in_month_of_next_mark = days_in_month(next_mark)
28
-
29
- month_distance.times do
30
-
31
- prev_mark = next_mark
32
- next_mark += days_in_month_of_next_mark * IceCube::ONE_DAY
33
-
34
- # only moving one day at a time, so this suffices
35
- months_covered = next_mark.month - prev_mark.month
36
- months_covered += 12 if months_covered < 0
37
-
38
- # step back to the end of the previous month of months_covered went too far
39
- if months_covered == 2
40
- next_mark -= next_mark.mday * IceCube::ONE_DAY
41
- end
42
-
43
- days_in_month_of_next_mark = days_in_month(next_mark)
44
- next_mark = adjust(next_mark, prev_mark)
45
-
46
- end
47
-
48
- # at the end, there's a chance we're not on the correct day,
49
- # but if we're not - we will always be behind it in the correct month
50
- # if there exists no proper day in the month for us, return nil - otherwise, return that date
51
-
52
- if days_in_month_of_next_mark >= date.mday
53
- next_mark += (date.mday - next_mark.mday) * IceCube::ONE_DAY
54
- end
55
-
39
+ # Get the beginning of a date
40
+ def self.beginning_of_date(date)
41
+ date.respond_to?(:beginning_of_day) ?
42
+ date.beginning_of_day :
43
+ Time.local(date.year, date.month, date.day, 0, 0, 0)
56
44
  end
57
-
58
- def self.adjust(goal, date)
59
- return goal if goal.utc_offset == date.utc_offset
60
- goal - goal.utc_offset + date.utc_offset
45
+
46
+ # Get the end of a date
47
+ def self.end_of_date(date)
48
+ date.respond_to?(:end_of_day) ?
49
+ date.end_of_day :
50
+ Time.local(date.year, date.month, date.day, 23, 59, 59)
61
51
  end
62
-
63
- def self.is_leap?(year)
64
- (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
52
+
53
+ # Convert a symbol to a numeric month
54
+ def self.symbol_to_month(sym)
55
+ month = MONTHS[sym]
56
+ raise "No such month: #{sym}" unless month
57
+ month
65
58
  end
66
-
67
- def self.days_in_year(date)
68
- is_leap?(date.year) ? 366 : 365
59
+
60
+ # Convert a symbol to a numeric day
61
+ def self.symbol_to_day(sym)
62
+ day = DAYS[sym]
63
+ raise "No such day: #{sym}" unless day
64
+ day
69
65
  end
70
-
71
- def self.days_in_month(date)
72
- is_leap?(date.year) ? LeapYearMonthDays[date.month - 1] : CommonYearMonthDays[date.month - 1]
66
+
67
+ # Return the count of the number of times wday appears in the month,
68
+ # and which of those time falls on
69
+ def self.which_occurrence_in_month(time, wday)
70
+ first_occurrence = ((7 - Time.utc(time.year, time.month, 1).wday) + time.wday) % 7 + 1
71
+ this_weekday_in_month_count = ((days_in_month(time) - first_occurrence + 1) / 7.0).ceil
72
+ nth_occurrence_of_weekday = (time.mday - first_occurrence) / 7 + 1
73
+ [nth_occurrence_of_weekday, this_weekday_in_month_count]
73
74
  end
74
-
75
- def self.ical_utc_format(time)
76
- time = time.dup.utc
77
- "#{time.strftime('%Y%m%dT%H%M%SZ')}" # utc time
75
+
76
+ # Get the days in the month for +time
77
+ def self.days_in_month(time)
78
+ days_in_month_year(time.month, time.year)
78
79
  end
79
-
80
- def self.ical_format(time, force_utc)
81
- time = time.dup.utc if force_utc
82
- if time.utc?
83
- ":#{time.strftime('%Y%m%dT%H%M%SZ')}" # utc time
84
- else
85
- ";TZID=#{time.strftime('%Z:%Y%m%dT%H%M%S')}" # local time specified
80
+
81
+ def self.days_in_next_month(time)
82
+ # Get the next month
83
+ year = time.year
84
+ month = time.month + 1
85
+ if month > 12
86
+ month %= 12
87
+ year += 1
86
88
  end
89
+ # And then determine
90
+ days_in_month_year(month, year)
87
91
  end
88
-
89
- def self.ical_duration(duration)
90
- hours = duration / 3600; duration %= 3600
91
- minutes = duration / 60; duration %= 60
92
- repr = ''
93
- repr << "#{hours}H" if hours > 0
94
- repr << "#{minutes}M" if minutes > 0
95
- repr << "#{duration}S" if duration > 0
96
- "PT#{repr}"
92
+
93
+ def self.days_in_month_year(month, year)
94
+ is_leap?(year) ? LEAP_YEAR_MONTH_DAYS[month - 1] : COMMON_YEAR_MONTH_DAYS[month - 1]
97
95
  end
98
96
 
97
+ # Number of days in a year
98
+ def self.days_in_year(time)
99
+ is_leap?(time.year) ? 366 : 365
100
+ end
101
+
102
+ # Number of days to n years
103
+ def self.days_in_n_years(time, year_distance)
104
+ sum = 0
105
+ wrapper = TimeWrapper.new(time)
106
+ year_distance.times do
107
+ diy = days_in_year(wrapper.to_time)
108
+ sum += diy
109
+ wrapper.add(:day, diy)
110
+ end
111
+ sum
112
+ end
113
+
114
+ # The number of days in n months
115
+ def self.days_in_n_months(time, month_distance)
116
+ # move to a safe spot in the month to make this computation
117
+ desired_day = time.day
118
+ time -= IceCube::ONE_DAY * (time.day - 27) if time.day >= 28
119
+ # move n months ahead
120
+ sum = 0
121
+ wrapper = TimeWrapper.new(time)
122
+ month_distance.times do
123
+ dim = days_in_month(wrapper.to_time)
124
+ sum += dim
125
+ wrapper.add(:day, dim)
126
+ end
127
+ # now we can move to the desired day
128
+ dim = days_in_month(wrapper.to_time)
129
+ if desired_day > dim
130
+ sum -= desired_day - dim
131
+ end
132
+ sum
133
+ end
134
+
135
+ # Given a year, return a boolean indicating whether it is
136
+ # a leap year or not
137
+ def self.is_leap?(year)
138
+ (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
139
+ end
140
+
141
+ # A utility class for safely moving time around
142
+ class TimeWrapper
143
+
144
+ def initialize(time, dst_adjust = true)
145
+ @dst_adjust = dst_adjust
146
+ @time = time
147
+ end
148
+
149
+ # Get the wrapper time back
150
+ def to_time
151
+ @time
152
+ end
153
+
154
+ # DST-safely add an interval of time to the wrapped time
155
+ def add(type, val)
156
+ type = :day if type == :wday
157
+ adjust do
158
+ @time += case type
159
+ when :year then TimeUtil.days_in_n_years(@time, val) * ONE_DAY
160
+ when :month then TimeUtil.days_in_n_months(@time, val) * ONE_DAY
161
+ when :day then val * ONE_DAY
162
+ when :hour then val * ONE_HOUR
163
+ when :min then val * ONE_MINUTE
164
+ when :sec then val
165
+ end
166
+ end
167
+ end
168
+
169
+ # Clear everything below a certain type
170
+ CLEAR_ORDER = [:sec, :min, :hour, :day, :month, :year]
171
+ def clear_below(type)
172
+ type = :day if type == :wday
173
+ CLEAR_ORDER.each do |ptype|
174
+ break if ptype == type
175
+ adjust do
176
+ send(:"clear_#{ptype}")
177
+ end
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ def adjust(&block)
184
+ if @dst_adjust
185
+ off = @time.utc_offset
186
+ yield
187
+ diff = off - @time.utc_offset
188
+ @time += diff if diff != 0
189
+ else
190
+ yield
191
+ end
192
+ end
193
+
194
+ def clear_sec
195
+ @time -= @time.sec
196
+ end
197
+
198
+ def clear_min
199
+ @time -= (@time.min * ONE_MINUTE)
200
+ end
201
+
202
+ def clear_hour
203
+ @time -= (@time.hour * ONE_HOUR)
204
+ end
205
+
206
+ # Move to the first of the month, 0 hours
207
+ def clear_day
208
+ @time -= (@time.day - 1) * IceCube::ONE_DAY
209
+ end
210
+
211
+ # Clear to january 1st
212
+ def clear_month
213
+ @time -= ONE_DAY
214
+ until @time.month == 12
215
+ @time -= TimeUtil.days_in_month(@time) * ONE_DAY
216
+ end
217
+ @time += ONE_DAY
218
+ end
219
+
220
+ def clear_year
221
+ end
222
+
223
+ end
224
+
99
225
  end
100
226
 
101
227
  end
@@ -0,0 +1,107 @@
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 validated
51
+ # in this class. This is okay now, since we never expose it - but if we ever
52
+ # do - we should check that above this line, and return nil if end_time is past
53
+ @uses += 1 if time
54
+ time
55
+ end
56
+
57
+ def to_s
58
+ builder = StringBuilder.new
59
+ @validations.each do |name, validations|
60
+ validations.each do |validation|
61
+ validation.build_s(builder)
62
+ end
63
+ end
64
+ builder.to_s
65
+ end
66
+
67
+ def to_hash
68
+ builder = HashBuilder.new(self)
69
+ @validations.each do |name, validations|
70
+ validations.each do |validation|
71
+ validation.build_hash(builder)
72
+ end
73
+ end
74
+ builder.to_hash
75
+ end
76
+
77
+ def to_ical
78
+ builder = IcalBuilder.new
79
+ @validations.each do |name, validations|
80
+ validations.each do |validation|
81
+ validation.build_ical(builder)
82
+ end
83
+ end
84
+ builder.to_s
85
+ end
86
+
87
+ # Get the collection that contains validations of a certain type
88
+ def validations_for(key)
89
+ @validations ||= {}
90
+ @validations[key] ||= []
91
+ end
92
+
93
+ # Fully replace validations
94
+ def replace_validations_for(key, arr)
95
+ @validations[key] = arr
96
+ end
97
+
98
+ # Remove the specified base validations
99
+ def clobber_base_validations(*types)
100
+ types.each do |type|
101
+ @validations.delete(:"base_#{type}")
102
+ end
103
+ end
104
+
105
+ end
106
+
107
+ 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