EdvardM-recurrence 0.1.16 → 0.1.17

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 (3) hide show
  1. data/lib/recurrence.rb +398 -0
  2. data/spec/spec_helper.rb +2 -0
  3. metadata +5 -2
@@ -0,0 +1,398 @@
1
+ =begin rdoc
2
+ Simple class for recurring things. The idea is to decouple recurrence completely from other objects. A Recurrence instance
3
+ offers two key methods for finding out if something recurs at given date: the method <tt>recurs_on?(time)</tt> and +each+ to iterate through recurrences.
4
+ =end
5
+
6
+ # Edvard Majakari <edvard@majakari.net>
7
+
8
+ require 'date'
9
+
10
+ class Date
11
+ def self.days_in_month(year, mon) # convenience method
12
+ civil(year, mon, -1).day
13
+ end
14
+ end
15
+
16
+ # Namespace for Recurrence support classes and modules
17
+ module RecurrenceBase
18
+ module SetOperations
19
+ # Return complement of the recurrence, ie. return value of recurs_on? is negated.
20
+ def complement
21
+ RecurrenceComplement.new(self, nil)
22
+ end
23
+
24
+ # Return union of the recurrences. For any recurrences or sets of recurrences +a+ and +b+, <tt>(a.join b).recurs_on?(t)</tt>
25
+ # returns true _iff_ either a OR b recurs on t.
26
+ def join(recurrence)
27
+ RecurrenceUnion.new(self, recurrence)
28
+ end
29
+ alias :| :join
30
+
31
+ # Return intersection of the recurrences. For any recurrences or sets of recurrences +a+ and +b+, <tt>(a.intersect b).recurs_on?(t)</tt>
32
+ # returns true _iff_ both a AND b recur on t.
33
+ def intersect(recurrence)
34
+ RecurrenceIntersection.new(self, recurrence)
35
+ end
36
+ alias :& :intersect
37
+
38
+ # Return difference of the recurrences. Note that the order is now significant. For any recurrences or sets of recurrences +a+ and +b+, <tt>(a.diff b).recurs_on?(t)</tt>
39
+ # returns true _iff_ a recurs on t AND b does NOT recur on t.
40
+ def diff(recurrence)
41
+ RecurrenceDifference.new(self, recurrence)
42
+ end
43
+ alias :- :diff
44
+ end
45
+
46
+ class RecurrenceProxy
47
+ include SetOperations
48
+
49
+ def initialize(a, b)
50
+ @a = a
51
+ @b = b
52
+ end
53
+
54
+ def class
55
+ ::Recurrence
56
+ end
57
+ end
58
+
59
+ class RecurrenceUnion < RecurrenceProxy
60
+ def recurs_on?(t)
61
+ @a.recurs_on?(t) or @b.recurs_on?(t)
62
+ end
63
+ end
64
+
65
+ class RecurrenceIntersection < RecurrenceProxy
66
+ def recurs_on?(t)
67
+ @a.recurs_on?(t) and @b.recurs_on?(t)
68
+ end
69
+ end
70
+
71
+ class RecurrenceComplement < RecurrenceProxy
72
+ def recurs_on?(t)
73
+ !@a.recurs_on?(t)
74
+ end
75
+ end
76
+
77
+ class RecurrenceDifference < RecurrenceProxy
78
+ def recurs_on?(t)
79
+ @a.recurs_on?(t) and !@b.recurs_on?(t)
80
+ end
81
+ end
82
+
83
+ class RecurrenceSymmetricDifference < RecurrenceProxy
84
+ def recurs_on?(t)
85
+ @a.recurs_on?(t) && !@b.recurs_on?(t) or !@a.recurs_on?(t) && @b.recurs_on?(t)
86
+ end
87
+ end
88
+
89
+ module RecurrenceMixin
90
+ attr_reader :recur_until
91
+
92
+ _RECURRENCES = [:day, :week, :month, :year]
93
+ _RECURRENCE_EXTENSIONS = [:workday, :weekend]
94
+
95
+ DAYS = [:sunday, :monday, :tuesday, :wednesday, :thursday, :friday, :saturday]
96
+ DAILY_RECURRENCES = DAYS + _RECURRENCES
97
+
98
+ RECURRENCE_SELECTORS = {
99
+ :every => DAILY_RECURRENCES + _RECURRENCE_EXTENSIONS,
100
+ :every_first => DAILY_RECURRENCES,
101
+ :every_second => DAILY_RECURRENCES,
102
+ :every_third => DAILY_RECURRENCES,
103
+ :every_last => DAILY_RECURRENCES,
104
+ :every_nth => DAILY_RECURRENCES
105
+ }
106
+
107
+ # Return true if the instance recurs on given time. Note that only the date part is taken into account. Support the same
108
+ # time arguments as +new+.
109
+ def recurs_on?(date_thing)
110
+ date = evaluate_date_arg(date_thing)
111
+ return false unless date_between_begin_end(date)
112
+
113
+ case @recurrence_repeat
114
+ when :every
115
+ repeat_every_since(start_date, date, @recurrence_type)
116
+ when :every_first, :every_second, :every_third, :every_last, :every_nth
117
+ if !@recurrence_options[:of]
118
+ recurrence_repeats_on? start_date, date, @recurrence_type, every_star_to_num
119
+ else
120
+ sym_to_num = {:every_first => 1, :every_second => 2, :every_third => 3, :every_last => -1}
121
+ n = sym_to_num[@recurrence_repeat]
122
+ weekday = @recurrence_type
123
+ weekday_is_nth_in?(n, @recurrence_options[:of], weekday, date)
124
+ end
125
+ else
126
+ raise "Oh noes1!1! Cheezburger denied wid rekorrenz repiet #{@recurrence_repeat}"
127
+ end
128
+ end
129
+
130
+ def to_s
131
+ ":#{@recurrence_repeat} => :#{@recurrence_type}, :start_date => #{self.start_date}, :recur_until => #{@recur_until}"
132
+ end
133
+
134
+ def recur_from
135
+ deprecation_warning(:recur_from, :start_date)
136
+ start_date
137
+ end
138
+
139
+ def deprecation_warning(deprecated_method, new_method)
140
+ klass = self.class
141
+ warn "#{klass}##{deprecated_method} is deprecated, please use #{klass}##{new_method} instead"
142
+ end
143
+
144
+ def start_date
145
+ r = @recur_from
146
+ Date.new(r.year, r.month, r.day)
147
+ end
148
+
149
+ # Return weekday symbol of the initial time used for recurrence (eg. :monday). The optional format can be either <tt>:long</tt>
150
+ # (default) or <tt>:short</tt>.
151
+ #
152
+ # Examples:
153
+ #
154
+ # r = Recurrence.new([2008, 1, 1], :every => :day)
155
+ # r.starting_dow # => :tuesday
156
+ # r.starting_dow(:short) # => :tue
157
+ def starting_dow(format=nil)
158
+ # TODO: rather modify Date class locally so that it returns weekday symbol
159
+ format ||= :long
160
+ wday = DAYS[start_date.wday]
161
+ case format
162
+ when :long
163
+ wday
164
+ when :short
165
+ wday.to_s[0..2].to_sym
166
+ else
167
+ raise ArgumentError, 'invalid format'
168
+ end
169
+ end
170
+
171
+ # Iterate through all recurrences, always yielding the next time object.
172
+ # WARNING: incomplete implementation. Only works for every_nth daily recurrences for now.
173
+ def each_day
174
+ date = start_date
175
+ multiplier = every_star_to_num
176
+
177
+ rtype = @recurrence_type
178
+ old_date = date.dup
179
+ orig_day = date.day
180
+
181
+ if weekday_recurrence?(rtype)
182
+ day_diff = DAYS.index(rtype) - date.wday
183
+ day_diff = 7 + day_diff if day_diff < 0
184
+ date += day_diff
185
+ end
186
+
187
+ loop do
188
+ yield date
189
+ if rtype == :day
190
+ date += multiplier
191
+ elsif rtype == :week || weekday_recurrence?(rtype)
192
+ date += multiplier*7
193
+ elsif rtype == :month
194
+ y, m, d = date.year, date.mon, date.day
195
+ m += every_star_to_num
196
+ y_inc, m_rem = m.divmod 13
197
+ m_rem = 1 if m_rem.zero?
198
+ y += y_inc
199
+ dim = Date.days_in_month(y, m_rem)
200
+ d = [dim, orig_day].min
201
+ date = Date.new(y, m_rem, d)
202
+ elsif rtype == :year
203
+ y, m, d = date.year, date.mon, date.day
204
+ date = Date.new(y+every_star_to_num, m, d)
205
+ else
206
+ raise ArgumentError, "Oh noes! #{rtype}!"
207
+ end
208
+
209
+ # make sure the date changes in the loop!
210
+ fail unless date > old_date
211
+ old_date = date
212
+ end # loop
213
+ end # each_day
214
+
215
+ #
216
+ # Private instance methods
217
+ #
218
+
219
+ private
220
+
221
+ def weekday_recurrence?(rtype)
222
+ DAYS.include? rtype
223
+ end
224
+
225
+ def every_star_to_num
226
+ hsh = {
227
+ :every => 1,
228
+ :every_second => 2,
229
+ :every_third => 3,
230
+ :every_nth => @recurrence_options[:interval]
231
+ }
232
+ hsh[@recurrence_repeat]
233
+ end
234
+
235
+ def parse_recurrence_options(opts)
236
+ repeat = opts.keys.detect { |key| RECURRENCE_SELECTORS.include? key }
237
+ raise ArgumentError, "missing required repeat modifier" unless repeat
238
+ recurrence_type = opts[repeat]
239
+
240
+ err_msg = "invalid recurrence type #{recurrence_type} for repeat #{repeat}"
241
+ raise ArgumentError, err_msg unless RECURRENCE_SELECTORS[repeat].include? recurrence_type
242
+ [repeat, recurrence_type]
243
+ end
244
+
245
+ def date_delta(from_date, to_date)
246
+ (to_date - from_date).to_i # :- returns Rational, so we need to_i
247
+ end
248
+
249
+ def weekend?(time)
250
+ [:sunday, :saturday].include? DAYS[time.wday]
251
+ end
252
+
253
+ def repeat_every_since(start_date, time, recurrence_type)
254
+ case recurrence_type
255
+ when :weekend
256
+ weekend?(time)
257
+ when :workday
258
+ !weekend?(time)
259
+ else
260
+ recurrence_repeats_on?(start_date, time, recurrence_type, 1)
261
+ end
262
+ end
263
+
264
+ def recurrence_repeats_on?(start_date, time, recurrence_type, n)
265
+ case recurrence_type
266
+ when :day
267
+ date_delta(start_date, time) % n == 0
268
+ when :week
269
+ date_delta(start_date, time) % (n*7) == 0
270
+ when :month
271
+ start_date.day == time.day && (time.mon - start_date.mon) % n == 0
272
+ when :year
273
+ start_date.day == time.day && start_date.mon == time.mon && (time.year - start_date.year) % n == 0
274
+ when *DAYS
275
+ DAYS[time.wday] == recurrence_type
276
+ else
277
+ raise ArgumentError, "invalid recurrence type #{@recurrence_type}"
278
+ end
279
+ end
280
+
281
+ def weekday_is_nth_in?(n, period, weekday, time)
282
+ case period
283
+ when :month
284
+ nth_weekday_in_month?(n, weekday, time)
285
+ else
286
+ raise ArgumentError, 'oh noes'
287
+ end
288
+ end
289
+
290
+ def nth_weekday_in_month?(n, weekday, time)
291
+ if n == -1
292
+ dim = Date.days_in_month(time.year, time.mon)
293
+ DAYS[time.wday] == weekday && time.day > dim - 7
294
+ elsif n > 1
295
+ DAYS[time.wday] == weekday && time.day > 7*(n-1) && time.day <= 7*n
296
+ else
297
+ DAYS[time.wday] == weekday && time.day < 8
298
+ end
299
+ end
300
+
301
+ def evaluate_date_arg(time_arg)
302
+ case time_arg
303
+ when String
304
+ Date.parse(time_arg)
305
+ when Array
306
+ Date.new(*time_arg)
307
+ when Symbol
308
+ symbol_to_date(time_arg)
309
+ when Date
310
+ time_arg
311
+ when Time
312
+ Date.new(time_arg.year, time_arg.month, time_arg.day)
313
+ else
314
+ raise ArgumentError, "invalid timey thing passed as argument: #{time_arg.inspect}"
315
+ end
316
+ end
317
+
318
+ def symbol_to_date(sym)
319
+ case sym
320
+ when :epoch
321
+ Date.new(1970, 1, 1)
322
+ when :today
323
+ Date.today
324
+ else
325
+ raise ArgumentError, "invalid date spec #{sym}"
326
+ end
327
+ end
328
+
329
+ def date_between_begin_end(date)
330
+ prereq = start_date <= date
331
+ @recur_until ? prereq && date <= @recur_until : prereq
332
+ end
333
+ end
334
+ end
335
+
336
+
337
+ # Class for creating recurring objects. Also see the module RecurrenceBase::RecurrenceMixin for basic methods and
338
+ # RecurrenceBase::RecurrenceMixin for supported set operations.
339
+ class Recurrence
340
+ include RecurrenceBase::SetOperations
341
+ include RecurrenceBase::RecurrenceMixin
342
+
343
+ attr_reader :recurrence_repeat, :recurrence_type, :recur_until
344
+
345
+ alias :each :each_day # each is more convenient with Recurrence instances, but in a mixin it would probably be a bad idea
346
+
347
+ # Instantiate a new object. +init_time+ can be any of the following four argument types:
348
+ # - Date instance
349
+ # - Date string of the form yyyy-mm-dd
350
+ # - Time instance (only date part will be taken into account)
351
+ # - Array of integers [Y, m, d] (eg. [2008, 7, 25])
352
+ # - Special symbol :epoch, denoting the common *nix time epoch 1970-01-01
353
+ # - Special symbol :today, denoting the current day
354
+ #
355
+ # +init_time+ is used for deferring whether the recurrence is valid at given time. All calls to recurs_on?
356
+ # with time before +init_time+ return false. The hour/minute part is ignored.
357
+ #
358
+ # The second argument is the option hash specifying the type of recurrence. The hash key represents the frequency
359
+ # of the recurrence (:every, :every_first, :every_second, :every_third, :every_nth)
360
+ # and the value specifies the time unit :day, :week, :month, :year (sometimes also :weekend or specific day of the week,
361
+ # see below).
362
+ #
363
+ # *Note* that every_first and every_second refer to specific _weekday_ and make sense only with monthly and yearly
364
+ # recurrences. They also require additional information like :of => :month or :of => :year.
365
+ #
366
+ # Examples:
367
+ #
368
+ # Recurrence.new(:epoch, :every_second => :day) # recur every other day, starting from epoch
369
+ # Recurrence.new(:epoch, :every_nth => :day, :interval => 10) # recur every 10th day, starting from epoch
370
+ #
371
+ # # recur only on the first wednesday of a month starting from today
372
+ # Recurrence.new(:today, :every_first => :wednesday, :of => :month)
373
+ #
374
+ # # recur only on every last thursday of a month, starting from today
375
+ # Recurrence.new(:today, :every_last => :thursday, :of => :month)
376
+ #
377
+ # Recurrence.new([2008, 10, 7], :every => :week) # 2008-01-07 was xday, so recur every xday
378
+ # Recurrence.new([2008, 10, 7], :every => :wednesday) # recur every wednesday starting from 2008-10-07
379
+ # Recurrence.new("2008-09-04", :every => :month) # Recur on the 4th day of every month
380
+ def initialize(init_time, options)
381
+ # these are public attrs
382
+ @recur_from = evaluate_date_arg(init_time)
383
+
384
+ until_time = options.delete(:until)
385
+ @recur_until = until_time.nil? ? nil : evaluate_date_arg(until_time)
386
+ @recurrence_repeat, @recurrence_type = parse_recurrence_options(options)
387
+
388
+ # this is private
389
+ @recurrence_options = options
390
+ end
391
+
392
+ def ==(other)
393
+ self.start_date == other.start_date &&
394
+ self.recur_until == other.recur_until &&
395
+ self.recurrence_type == other.recurrence_type &&
396
+ self.recurrence_repeat == other.recurrence_repeat
397
+ end
398
+ end
@@ -0,0 +1,2 @@
1
+ require 'spec'
2
+ require File.join(File.dirname(__FILE__), '../lib/recurrence')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: EdvardM-recurrence
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.16
4
+ version: 0.1.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Edvard Majakari
@@ -22,9 +22,12 @@ extensions: []
22
22
  extra_rdoc_files:
23
23
  - README.textile
24
24
  files:
25
+ - lib/recurrence.rb
26
+ - spec/recurrence_spec.rb
27
+ - spec/spec_helper.rb
25
28
  - README.textile
26
29
  has_rdoc: "true"
27
- homepage:
30
+ homepage: http://github.com/EdvardM/recurrence/
28
31
  post_install_message:
29
32
  rdoc_options: []
30
33