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.
- data/lib/recurrence.rb +398 -0
- data/spec/spec_helper.rb +2 -0
- metadata +5 -2
data/lib/recurrence.rb
ADDED
@@ -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
|
data/spec/spec_helper.rb
ADDED
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.
|
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
|
|