ggoodale-recurring 0.5.3 → 0.5.4

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.
data/History.txt CHANGED
@@ -1,4 +1,4 @@
1
- == 0.5.3 / 2009-8-12
1
+ == 0.5.4 / 2009-8-12
2
2
 
3
3
  * fixed issue with schedules of the form 'every n months on the first <weekday>' due to inconsistent handling of week starting day.
4
4
 
data/Rakefile CHANGED
@@ -23,7 +23,7 @@ specification = Gem::Specification.new do |s|
23
23
  s.homepage = %q{http://jchris.mfdz.com}
24
24
  s.description = %q{Recurring allows you to define Schedules, which can tell you whether or not a given Time falls in the Schedule, as well as being able to return a list of times which match the Schedule within a given range.}
25
25
  s.authors = ["Chris Anderson"]
26
- s.files = ["History.txt", "Manifest.txt", "README.txt", "Rakefile", "lib/recurring.rb", "spec/date_language_spec.rb", "spec/schedule_spec.rb"]
26
+ s.files = ["History.txt", "Manifest.txt", "README.txt", "Rakefile", "lib/recurring.rb", "lib/date_language.rb", "lib/schedule.rb", "spec/date_language_spec.rb", "spec/schedule_spec.rb"]
27
27
  s.add_development_dependency(%q<rspec>, [">= 0.7.4"])
28
28
  s.has_rdoc = true
29
29
  end
@@ -0,0 +1,25 @@
1
+ module Recurring
2
+
3
+
4
+ # A wrapper for Schedule which allows its arguments to be designated in a block. _Under Construction_
5
+ class DateLanguage
6
+ class << self
7
+ def tell &block
8
+ x = self.new
9
+ x.instance_eval &block
10
+ x
11
+ end
12
+ end
13
+
14
+ attr_reader :frequency
15
+
16
+ def every(frequency=1, unit=nil, options={})
17
+ @frequency = frequency
18
+ end
19
+
20
+ def times string
21
+ end
22
+ end
23
+
24
+ end
25
+
data/lib/schedule.rb ADDED
@@ -0,0 +1,447 @@
1
+ module Recurring
2
+
3
+ VERSION = '0.5.4'
4
+
5
+ class << self
6
+ # returns a number starting with 1. Needs to assume that weeks start on sunday
7
+ # for beginning_of_next to work at the scale of weeks.
8
+ def week_in_month date
9
+ # Work out the first date in the month
10
+ first_of_month = date - ((date.day - 1) * 86400)
11
+
12
+ # If the month starts on a Sunday, we're good.
13
+ if first_of_month.wday == 0
14
+ adjusted_day = date.day
15
+ else
16
+ # Otherwise, we need to offset by whatever partial week starts this month.
17
+ adjusted_day = date.day + first_of_month.wday
18
+ end
19
+ (((adjusted_day - 1).to_f / 7.0) + 1).floor
20
+ end
21
+
22
+ def negative_week_in_month date
23
+ end_of_month = (date.month < 12 ? Time.utc(date.year, date.month+1) : Time.utc(date.year + 1)) - 3600
24
+
25
+ (((end_of_month.day - date.day).to_f / 7.0) + 1).floor * -1
26
+ end
27
+
28
+ # just a wrapper for strftime
29
+ def week_of_year date
30
+ date.strftime('%U').to_i
31
+ end
32
+ end
33
+
34
+
35
+ # Initialize a Schedule object with the proper options to calculate occurances in
36
+ # that schedule. Schedule#new take a hash of options <tt>:unit, :frequency, :anchor, :weeks, :monthdays, :weekdays, :times</tt>
37
+ #
38
+ # = Yearly
39
+ # [Every two years from an anchor time] <tt>Recurring::Schedule.new :unit => 'years', :frequency => 2, :anchor => Time.utc(2006,4,15,10,30)</tt>
40
+ # [Every year in February and May on the 1st and 15th] <tt>Recurring::Schedule.new :unit => 'years', :months => ['feb', 'may'], :monthdays => [1,15]</tt>
41
+ # [Every year in February and May on the 1st and 15th] <tt>Recurring::Schedule.new :unit => 'years', :months => ['feb', 'may'], :monthdays => [1,15]</tt>
42
+ # = Monthly
43
+ # [Every two months from an anchor time] <tt>Recurring::Schedule.new :unit => 'months', :frequency => 2, :anchor => Time.utc(2006,4,15,10,30)</tt>
44
+ # [The first and fifteenth of every month] <tt>Recurring::Schedule.new :unit => 'months', :monthdays => [1,15]</tt>
45
+ # [The first and eighteenth of every third month] <tt>Recurring::Schedule.new :unit => 'months', :frequency => 3, :anchor => Time.utc(2006,4,15,10,30), :monthdays => [10,18]</tt>
46
+ # [The third Monday of every month at 6:30pm] <tt>Recurring::Schedule.new :unit => 'months', :weeks => 3, :weekdays => :monday, :times => '6:30pm'</tt>
47
+ # = Weekly
48
+ # [Monday, Wednesday, and Friday of every week] <tt>Recurring::Schedule.new :unit => 'weeks', :weekdays => %w{monday weds friday}</tt>
49
+ # [Every week at the same time on the same day of the week as the anchor (Weds at 5:30pm)] <tt>Recurring::Schedule.new :unit => 'weeks', :anchor => Time.utc(2006,12,6,17,30)</tt>
50
+ # [Equivalently, Every Wednesday at 5:30] <tt>Recurring::Schedule.new :unit => 'weeks', :weekdays => 'weds', :times => '5:30pm'</tt>
51
+ # = Daily
52
+ # [Everyday at the time of the anchor] <tt>Recurring::Schedule.new :unit => 'days', :anchor => Time.utc(2006,11,1,10,15,22)</tt>
53
+ # [Everyday at 7am and 5:45:20pm] <tt>Recurring::Schedule.new :unit => 'days', :times => '7am 5:45:20pm'</tt>
54
+ # = Hourly
55
+ # [Every hour at 15 minutes, 30 minutes, and 45 minutes and 30 seconds] <tt>Recurring::Schedule.new :unit => 'hours', :times => '0:15 4:30 0:45:30'</tt>
56
+ # [Offset every 2 hours from the anchor] <tt>Recurring::Schedule.new :unit => 'hours', :anchor => Time.utc(2001,5,15,11,17)</tt>
57
+ # = Minutely
58
+ # [Every five minutes offset from the anchor] <tt>Recurring::Schedule.new :unit => 'minutes', :frequency => 5, :anchor => Time.utc(2006,9,1,10,30)</tt>
59
+ # [Every minute at second 15] <tt>Recurring::Schedule.new :unit => 'minutes', :times => '0:0:15'</tt>
60
+ #
61
+ # See the specs using "rake spec" for even more examples.
62
+ class Schedule
63
+
64
+ attr_reader :unit, :frequency, :anchor, :months, :weeks, :monthdays, :weekdays, :times
65
+
66
+ # Options hash has keys <tt>:unit, :frequency, :anchor, :weeks, :monthdays, :weekdays, :times</tt>
67
+ # * valid values for :unit are <tt>years, months, weeks, days, hours, minutes</tt>
68
+ # * :frequency defaults to 1
69
+ # * :anchor is required if the frequency is other than one
70
+ # * :weeks alongside :weekdays is used to specify the nth instance of a weekday in a month.
71
+ # * :weekdays takes an array of strings like <tt>%w{monday weds friday}</tt>
72
+ # * :monthdays takes an array of days of the month, eg. <tt>[1,7,15]</tt>
73
+ # * :times takes a string with a simple format. <tt>"4pm 5:15pm 6:45:30pm"</tt>
74
+ def initialize options
75
+ raise ArgumentError, 'specify a valid unit' unless options[:unit] &&
76
+ %w{years months weeks days hours minutes}.include?(options[:unit])
77
+ raise ArgumentError, 'frequency > 1 requires an anchor Time' if options[:frequency] && options[:frequency] != 1 && !options[:anchor]
78
+ @unit = options[:unit].to_sym
79
+ raise ArgumentError, 'weekdays are required with the weeks param, if there are times params' if @unit == :weeks &&
80
+ options[:times] &&
81
+ !options[:weekdays]
82
+ @frequency = options[:frequency] || 1
83
+ @anchor = options[:anchor]
84
+ @times = parse_times options[:times]
85
+ if options[:months]
86
+ @month_args = Array(options[:months]).collect{|d|d.to_s.downcase.to_sym}
87
+ raise ArgumentError, 'provide valid months' unless @month_args.all?{|m|ordinal_month(m)}
88
+ @months = @month_args.collect{|m|ordinal_month(m)}
89
+ end
90
+
91
+ @weeks = Array(options[:weeks]).collect{|n|n.to_i} if options[:weeks]
92
+ if options[:weekdays]
93
+ @weekdays_args = Array(options[:weekdays]).collect{|d|d.to_s.downcase.to_sym}
94
+ raise ArgumentError, 'provide valid weekdays' unless @weekdays_args.all?{|w|ordinal_weekday(w)}
95
+ @weekdays = @weekdays_args.collect{|w|ordinal_weekday(w)}
96
+ end
97
+ @monthdays = Array(options[:monthdays]).collect{|n|n.to_i} if options[:monthdays]
98
+
99
+
100
+ @anchor_multiple = options[:times].nil? && options[:weeks].nil? && options[:weekdays].nil? && options[:monthdays].nil?
101
+ end
102
+
103
+ def timeout! time
104
+ @timeout = time
105
+ end
106
+
107
+ # Returns true or false depending on whether or not the time is included in the schedule.
108
+ def include? date
109
+ @resolution = nil
110
+ return true if check_anchor? && date == @anchor
111
+ return mismatch(:year) unless year_matches?(date) if @unit == :years
112
+ return mismatch(:month) unless month_matches?(date) if [:months, :years].include?(@unit)
113
+ return mismatch(:week) unless week_matches?(date) if [:years, :months, :weeks].include?(@unit)
114
+ if [:years, :months, :weeks, :days].include?(@unit)
115
+ return mismatch(:day) unless day_matches?(date)
116
+ return mismatch(:time) unless time_matches?(date)
117
+ end
118
+ if @unit == :hours
119
+ return mismatch(:hour) unless hour_matches?(date)
120
+ return mismatch(:sub_hour) unless sub_hour_matches?(date)
121
+ end
122
+ if @unit == :minutes
123
+ return mismatch(:minute) unless minute_matches?(date)
124
+ return mismatch(:second) unless second_matches?(date)
125
+ end
126
+ @resolution = nil
127
+ true
128
+ end
129
+
130
+ # Starts from the argument time, and returns the next included time. Returns the argument if it is included in the schedule.
131
+ def find_next date
132
+ loop do
133
+ return date if include?(date)
134
+ #puts "#{@resolution} : #{date}"
135
+ date = beginning_of_next @resolution, date
136
+ end
137
+ end
138
+
139
+ # Starts from the argument time, and works backwards until it hits a time that is included
140
+ def find_previous date
141
+ loop do
142
+ return date if include?(date)
143
+ #puts "#{@resolution} : #{date}"
144
+ date = end_of_previous @resolution, date
145
+ end
146
+ end
147
+
148
+ # Takes a range which responds to <tt>first</tt> and <tt>last</tt>, returning Time objects. The arguments need only to be duck-type compatible with Time#year, #month, #day, #hour, #min, #sec, #wday etc.
149
+ #
150
+ # <tt>rs.find_in_range(Time.now, Time.now+24*60*60)</tt>
151
+ #
152
+ # or
153
+ #
154
+ # <tt>range = (Time.now..Time.now+24*60*60)</tt>
155
+ #
156
+ # <tt>rs.find_in_range(range)</tt>
157
+ def find_in_range *args
158
+ if args[0].respond_to?(:first) && args[0].respond_to?(:last)
159
+ t_start = args[0].first
160
+ t_end = args[0].last
161
+ else
162
+ t_start = args[0]
163
+ t_end = args[1]
164
+ end
165
+ opts = args.last if args.last.respond_to?(:keys)
166
+ if opts
167
+ limit = opts[:limit]
168
+ end
169
+ result = []
170
+ count = 1
171
+ loop do
172
+ rnext = find_next t_start
173
+ break if count > limit if limit
174
+ break if rnext > t_end
175
+ result << rnext
176
+ t_start = rnext + 1
177
+ count += 1
178
+ end
179
+ result
180
+ end
181
+
182
+ # Two Schedules are equal if they have the same attributes.
183
+ def == other
184
+ return false unless self.class == other.class
185
+ [:unit, :frequency, :anchor, :weeks, :monthdays, :weekdays, :times].all? do |attribute|
186
+ self.send(attribute) == other.send(attribute)
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def end_of_previous scope, date
193
+ case scope
194
+ when :year
195
+ Time.utc(date.year) - 1
196
+ when :month
197
+ Time.utc(date.year, date.month) -1
198
+ when :week
199
+ to_sunday = date.wday
200
+ previous_week = (date - to_sunday*24*60*60)
201
+ Time.utc(previous_week.year, previous_week.month, previous_week.day) - 1
202
+ when :day
203
+ Time.utc(date.year, date.month, date.day) - 1
204
+ when :time
205
+ previous_time date
206
+ else
207
+ date - 1
208
+ end
209
+ end
210
+
211
+
212
+ def beginning_of_next scope, date
213
+ case scope
214
+ when :year
215
+ Time.utc(date.year + 1)
216
+ when :month
217
+ date.month < 12 ? Time.utc(date.year, date.month+1) : beginning_of_next(:year, date)
218
+ when :week
219
+ to_sunday = 7 - date.wday
220
+ next_week = (date + to_sunday*24*60*60)
221
+ next_week = next_week - ((next_week.day - 1) * 86400) if next_week.month != date.month
222
+ Time.utc(next_week.year, next_week.month, next_week.day)
223
+ when :day
224
+ dayp = date + (24*60*60)
225
+ Time.utc(dayp.year, dayp.month, dayp.day)
226
+ when :time
227
+ next_time date
228
+ when :hour
229
+ date.hour < 23 ? Time.utc(date.year, date.month, date.day, date.hour+1) : beginning_of_next(:day, date)
230
+ when :sub_hour
231
+ next_sub_hour date
232
+ else
233
+ date + 1
234
+ end
235
+ end
236
+
237
+ def previous_time date
238
+ me = {:hour => date.hour, :minute => date.min, :second => date.sec, :me => true}
239
+ my_times = times + [me]
240
+ my_times += [{:hour => @anchor.hour, :minute => @anchor.min, :second => @anchor.sec}] if check_anchor?
241
+ my_times.sort! do |a,b|
242
+ v = a[:hour] <=> b[:hour]
243
+ v = a[:minute] <=> b[:minute] if v == 0
244
+ v = a[:second] <=> b[:second] if v == 0
245
+ v
246
+ end
247
+ my_times.reverse!
248
+ ntime = my_times[my_times.index(me)+1]
249
+ if ntime
250
+ Time.utc(date.year, date.month, date.day, ntime[:hour], ntime[:minute], ntime[:second])
251
+ else
252
+ end_of_previous :day, date
253
+ end
254
+ end
255
+
256
+ def next_time date
257
+ me = {:hour => date.hour, :minute => date.min, :second => date.sec, :me => true}
258
+ my_times = times + [me]
259
+ my_times += [{:hour => @anchor.hour, :minute => @anchor.min, :second => @anchor.sec}] if check_anchor?
260
+ my_times.sort! do |a,b|
261
+ v = a[:hour] <=> b[:hour]
262
+ v = a[:minute] <=> b[:minute] if v == 0
263
+ v = a[:second] <=> b[:second] if v == 0
264
+ v
265
+ end
266
+ ntime = my_times[my_times.index(me)+1]
267
+ if ntime
268
+ Time.utc(date.year, date.month, date.day, ntime[:hour], ntime[:minute], ntime[:second])
269
+ else
270
+ beginning_of_next :day, date
271
+ end
272
+ end
273
+
274
+ def next_sub_hour date
275
+ me = {:minute => date.min, :second => date.sec, :me => true}
276
+ my_times = times + [me]
277
+ my_times += [{:minute => @anchor.min, :second => @anchor.sec}] if check_anchor?
278
+ my_times.sort! do |a,b|
279
+ v = a[:minute] <=> b[:minute]
280
+ v = a[:second] <=> b[:second] if v == 0
281
+ v
282
+ end
283
+ ntime = my_times[my_times.index(me)+1]
284
+ if ntime
285
+ Time.utc(date.year, date.month, date.day, date.hour, ntime[:minute], ntime[:second])
286
+ else
287
+ beginning_of_next :hour, date
288
+ end
289
+ end
290
+
291
+ def mismatch unit
292
+ @resolution = unit
293
+ false
294
+ end
295
+
296
+ def year_matches? date
297
+ return true if @frequency == 1
298
+ (date.year - @anchor.year) % @frequency == 0
299
+ end
300
+
301
+ def month_matches? date
302
+ if @unit == :months
303
+ return true if @frequency == 1
304
+ years_in_months = (date.year - @anchor.year) * 12
305
+ diff_months = date.month - @anchor.month
306
+ return (years_in_months + diff_months) % @frequency == 0
307
+ elsif @months
308
+ return @months.include?(date.month)
309
+ else
310
+ return false if date.month != @anchor.month
311
+ end
312
+
313
+ true
314
+ end
315
+
316
+ def week_matches? date
317
+ if @unit == :weeks
318
+ return true if @frequency == 1
319
+ return ((Recurring.week_of_year(date) - Recurring.week_of_year(@anchor)) % @frequency) == 0
320
+ end
321
+ if @weeks
322
+ @weeks.include?(Recurring.week_in_month(date)) || @weeks.include?(Recurring.negative_week_in_month(date))
323
+ else
324
+ true
325
+ end
326
+ end
327
+
328
+ def day_matches? date
329
+ if @unit == :days
330
+ return true if @frequency == 1
331
+ diff = Time.utc(date.year, date.month, date.day) - Time.utc(@anchor.year, @anchor.month, @anchor.day)
332
+ return (diff / 86400) % @frequency == 0
333
+ end
334
+ return @monthdays.include?(date.day) if @monthdays
335
+ return @weekdays.include?(date.wday) if @weekdays
336
+ if @unit == :weeks && check_anchor?
337
+ return @anchor.wday == date.wday
338
+ end
339
+ return true if check_anchor? && date.day == @anchor.day
340
+ end
341
+
342
+ def time_matches? date
343
+ #concerned with groups of hour minute second
344
+ if check_anchor?
345
+ return @anchor.hour == date.hour && @anchor.min == date.min && @anchor.sec == date.sec
346
+ end
347
+ @times.any? do |time|
348
+ time[:hour] == date.hour && time[:minute] == date.min && time[:second] == date.sec
349
+ end
350
+ end
351
+
352
+ def hour_matches? date
353
+ return true if @frequency == 1
354
+ diff = Time.utc(date.year, date.month, date.day, date.hour) - Time.utc(@anchor.year, @anchor.month, @anchor.day, @anchor.hour)
355
+ (diff / 3600) % @frequency == 0
356
+ end
357
+
358
+ def sub_hour_matches? date
359
+ if check_anchor?
360
+ return @anchor.min == date.min && @anchor.sec == date.sec
361
+ end
362
+ times.any? do |time|
363
+ time[:minute] == date.min && time[:second] == date.sec
364
+ end
365
+ end
366
+
367
+ def minute_matches? date
368
+ return true if @frequency == 1
369
+ diff = Time.utc(date.year, date.month, date.day, date.hour, date.min) - Time.utc(@anchor.year, @anchor.month, @anchor.day, @anchor.hour, @anchor.min)
370
+ (diff / 60) % @frequency == 0
371
+ end
372
+
373
+ def second_matches? date
374
+ if check_anchor?
375
+ return @anchor.sec == date.sec
376
+ end
377
+ times.any? do |time|
378
+ time[:second] == date.sec
379
+ end
380
+ end
381
+
382
+ def check_anchor?
383
+ @anchor && @anchor_multiple
384
+ end
385
+
386
+ def ordinal_weekday symbol
387
+ lookup = {0 => [:sunday, :sun],
388
+ 1 => [:monday, :mon],
389
+ 2 => [:tuesday, :tues],
390
+ 3 => [:wednesday, :weds],
391
+ 4 => [:thursday, :thurs],
392
+ 5 => [:friday, :fri],
393
+ 6 => [:saturday, :sat]}
394
+ pair = lookup.select{|k,v| v.include?(symbol)}.first
395
+ pair.first if pair
396
+ end
397
+
398
+ def ordinal_month symbol
399
+ lookup = {1 => [:january, :jan],
400
+ 2 => [:february, :feb],
401
+ 3 => [:march, :mar],
402
+ 4 => [:april, :apr],
403
+ 5 => [:may],
404
+ 6 => [:june, :jun],
405
+ 7 => [:july, :jul],
406
+ 8 => [:august, :aug],
407
+ 9 => [:september, :sept],
408
+ 10 => [:october, :oct],
409
+ 11 => [:november, :nov],
410
+ 12 => [:december, :dec]}
411
+ pair = lookup.select{|k,v| v.include?(symbol)}.first
412
+ pair.first if pair
413
+ end
414
+
415
+ def parse_times string
416
+ if string.nil? || string.empty?
417
+ return [{:hour => 0, :minute => 0, :second => 0}]
418
+ end
419
+ times = string.downcase.gsub(',','').split(' ')
420
+ parsed = times.collect do |st|
421
+ st = st.gsub /pm|am/, ''
422
+ am_pm = $&
423
+ time = {}
424
+ time[:hour], time[:minute], time[:second] = st.split(':').collect {|n| n.to_i}
425
+ time[:minute] ||= 0
426
+ time[:second] ||= 0
427
+ time[:hour] = time[:hour] + 12 if am_pm == 'pm' && time[:hour] < 12
428
+ time[:hour] = 0 if am_pm == 'am' && time[:hour] == 12
429
+ time
430
+ end
431
+ #this is an implementation of Array#uniq required because Hash#eql? is not a synonym for Hash#==
432
+ result = []
433
+ parsed.each_with_index do |h,i|
434
+ result << h unless parsed[(i+1)..parsed.length].include?(h)
435
+ end
436
+ result
437
+ end
438
+ end
439
+ end
440
+
441
+ # RDS does not match ranges as such, just times specified to varying precision
442
+ # eg, you can construct a Schedule that matches all of February, by not specifying the
443
+ # week, day, or time. If you want February through August, you'll have to specify all the months
444
+ # individually.
445
+
446
+ # Change of Behaviour in Recurring: Schedules include only points in time. The Mask model handles ranges.
447
+
metadata CHANGED
@@ -1,12 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ggoodale-recurring
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Anderson
8
- - Grant Goodale
9
- autorequire: recurring
8
+ autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
11
 
@@ -37,6 +36,8 @@ files:
37
36
  - README.txt
38
37
  - Rakefile
39
38
  - lib/recurring.rb
39
+ - lib/date_language.rb
40
+ - lib/schedule.rb
40
41
  - spec/date_language_spec.rb
41
42
  - spec/schedule_spec.rb
42
43
  has_rdoc: false