rrule 0.0.0 → 0.1.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.
- checksums.yaml +4 -4
- data/.gitignore +4 -0
- data/.travis.yml +3 -0
- data/CHANGELOG.md +7 -0
- data/CONTRIBUTING.md +17 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +202 -0
- data/README.md +71 -0
- data/Rakefile +30 -0
- data/lib/rrule.rb +26 -4
- data/lib/rrule/context.rb +137 -0
- data/lib/rrule/filters/by_month.rb +16 -0
- data/lib/rrule/filters/by_month_day.rb +16 -0
- data/lib/rrule/filters/by_week_day.rb +24 -0
- data/lib/rrule/filters/by_week_number.rb +16 -0
- data/lib/rrule/filters/by_year_day.rb +18 -0
- data/lib/rrule/frequencies/daily.rb +13 -0
- data/lib/rrule/frequencies/frequency.rb +34 -0
- data/lib/rrule/frequencies/monthly.rb +14 -0
- data/lib/rrule/frequencies/weekly.rb +29 -0
- data/lib/rrule/frequencies/yearly.rb +14 -0
- data/lib/rrule/generators/all_occurrences.rb +26 -0
- data/lib/rrule/generators/by_set_position.rb +35 -0
- data/lib/rrule/rule.rb +181 -0
- data/lib/rrule/weekday.rb +17 -0
- data/rrule.gemspec +20 -0
- data/spec/context_spec.rb +261 -0
- data/spec/filters/by_month_day_spec.rb +35 -0
- data/spec/filters/by_month_spec.rb +35 -0
- data/spec/filters/by_week_day_spec.rb +35 -0
- data/spec/filters/by_week_number_spec.rb +41 -0
- data/spec/filters/by_year_day_spec.rb +35 -0
- data/spec/frequencies/daily_spec.rb +55 -0
- data/spec/frequencies/monthly_spec.rb +57 -0
- data/spec/frequencies/weekly_spec.rb +57 -0
- data/spec/frequencies/yearly_spec.rb +52 -0
- data/spec/generators/all_occurrences_spec.rb +44 -0
- data/spec/generators/by_set_position_spec.rb +39 -0
- data/spec/rule_spec.rb +1988 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/weekday_spec.rb +34 -0
- metadata +88 -8
@@ -0,0 +1,16 @@
|
|
1
|
+
module RRule
|
2
|
+
class ByMonth
|
3
|
+
def initialize(by_months, context)
|
4
|
+
@by_months = by_months
|
5
|
+
@context = context
|
6
|
+
end
|
7
|
+
|
8
|
+
def reject?(i)
|
9
|
+
!by_months.include?(context.month_by_day_of_year[i])
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
attr_reader :by_months, :context
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module RRule
|
2
|
+
class ByMonthDay
|
3
|
+
def initialize(by_month_days, context)
|
4
|
+
@context = context
|
5
|
+
@positive_month_days, @negative_month_days = by_month_days.partition { |mday| mday > 0 }
|
6
|
+
end
|
7
|
+
|
8
|
+
def reject?(i)
|
9
|
+
!positive_month_days.include?(context.month_day_by_day_of_year[i]) && !negative_month_days.include?(context.negative_month_day_by_day_of_year[i])
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
attr_reader :context, :positive_month_days, :negative_month_days
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module RRule
|
2
|
+
class ByWeekDay
|
3
|
+
def initialize(weekdays, context)
|
4
|
+
@by_week_days = weekdays.map(&:index)
|
5
|
+
@context = context
|
6
|
+
end
|
7
|
+
|
8
|
+
def reject?(i)
|
9
|
+
masked?(i) || !matches_by_week_days?(i)
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def masked?(i)
|
15
|
+
context.day_of_year_mask && !context.day_of_year_mask[i]
|
16
|
+
end
|
17
|
+
|
18
|
+
def matches_by_week_days?(i)
|
19
|
+
by_week_days.empty? || by_week_days.include?(context.weekday_by_day_of_year[i])
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :by_week_days, :context
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module RRule
|
2
|
+
class ByWeekNumber
|
3
|
+
def initialize(by_week_numbers, context)
|
4
|
+
@by_week_numbers = by_week_numbers
|
5
|
+
@context = context
|
6
|
+
end
|
7
|
+
|
8
|
+
def reject?(i)
|
9
|
+
!by_week_numbers.include?(context.week_number_by_day_of_year[i]) && !by_week_numbers.include?(context.negative_week_number_by_day_of_year[i])
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
attr_reader :by_week_numbers, :context
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module RRule
|
2
|
+
class ByYearDay
|
3
|
+
def initialize(by_year_days, context)
|
4
|
+
@by_year_days = by_year_days
|
5
|
+
@context = context
|
6
|
+
end
|
7
|
+
|
8
|
+
def reject?(i)
|
9
|
+
!by_year_days.empty? &&
|
10
|
+
((i < context.year_length_in_days && !by_year_days.include?(i + 1) && !by_year_days.include?(i - context.year_length_in_days)) ||
|
11
|
+
(i >= context.year_length_in_days && !by_year_days.include?(i + 1 - context.year_length_in_days) && !by_year_days.include?(i - context.year_length_in_days - context.next_year_length_in_days)))
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
attr_reader :by_year_days, :context
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module RRule
|
2
|
+
class Frequency
|
3
|
+
attr_reader :current_date
|
4
|
+
|
5
|
+
def initialize(context)
|
6
|
+
@context = context
|
7
|
+
@current_date = context.dtstart
|
8
|
+
end
|
9
|
+
|
10
|
+
def advance
|
11
|
+
@current_date = current_date.advance(advance_by).tap do |new_date|
|
12
|
+
unless same_month(current_date, new_date)
|
13
|
+
context.rebuild(new_date.year, new_date.month)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def possible_days
|
19
|
+
fail NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :context
|
25
|
+
|
26
|
+
def same_month(first_date, second_date)
|
27
|
+
first_date.month == second_date.month && first_date.year == second_date.year
|
28
|
+
end
|
29
|
+
|
30
|
+
def advance_by
|
31
|
+
fail NotImplementedError
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module RRule
|
2
|
+
class Monthly < Frequency
|
3
|
+
def possible_days
|
4
|
+
# yday is 1-indexed, need results 0-indexed
|
5
|
+
(current_date.beginning_of_month.yday - 1..current_date.end_of_month.yday - 1).to_a
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def advance_by
|
11
|
+
{ months: context.options[:interval] }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module RRule
|
2
|
+
class Weekly < Frequency
|
3
|
+
def possible_days
|
4
|
+
i = current_date.yday - 1
|
5
|
+
possible_days = []
|
6
|
+
7.times do
|
7
|
+
possible_days.push(i)
|
8
|
+
i += 1
|
9
|
+
break if context.weekday_by_day_of_year[i] == context.options[:wkst]
|
10
|
+
end
|
11
|
+
possible_days
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def advance_by
|
17
|
+
{ days: days_to_advance(current_date) }
|
18
|
+
end
|
19
|
+
|
20
|
+
def days_to_advance(date)
|
21
|
+
if context.options[:wkst] > date.wday
|
22
|
+
-(date.wday + 1 + (6 - context.options[:wkst])) + context.options[:interval] * 7
|
23
|
+
else
|
24
|
+
-(date.wday - context.options[:wkst]) + context.options[:interval] * 7
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module RRule
|
2
|
+
class AllOccurrences
|
3
|
+
attr_reader :context
|
4
|
+
|
5
|
+
def initialize(context)
|
6
|
+
@context = context
|
7
|
+
end
|
8
|
+
|
9
|
+
def combine_dates_and_times(dayset, timeset)
|
10
|
+
dayset.compact.map { |i| context.first_day_of_year + i }.flat_map do |date|
|
11
|
+
timeset.map do |time|
|
12
|
+
Time.use_zone(context.tz) do
|
13
|
+
Time.zone.local(
|
14
|
+
date.year,
|
15
|
+
date.month,
|
16
|
+
date.day,
|
17
|
+
time[:hour],
|
18
|
+
time[:minute],
|
19
|
+
time[:second]
|
20
|
+
)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module RRule
|
2
|
+
class BySetPosition
|
3
|
+
attr_reader :by_set_positions, :context
|
4
|
+
|
5
|
+
def initialize(by_set_positions, context)
|
6
|
+
@by_set_positions = by_set_positions
|
7
|
+
@context = context
|
8
|
+
end
|
9
|
+
|
10
|
+
def combine_dates_and_times(dayset, timeset)
|
11
|
+
valid_dates(dayset).flat_map do |date|
|
12
|
+
timeset.map do |time|
|
13
|
+
Time.use_zone(context.tz) do
|
14
|
+
Time.zone.local(
|
15
|
+
date.year,
|
16
|
+
date.month,
|
17
|
+
date.day,
|
18
|
+
time[:hour],
|
19
|
+
time[:minute],
|
20
|
+
time[:second]
|
21
|
+
)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def valid_dates(dayset)
|
28
|
+
dayset.compact!
|
29
|
+
by_set_positions.map do |position|
|
30
|
+
position -= 1 if position > 0
|
31
|
+
dayset[position]
|
32
|
+
end.compact.map { |i| context.first_day_of_year + i }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
data/lib/rrule/rule.rb
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
module RRule
|
2
|
+
class Rule
|
3
|
+
attr_reader :rrule, :dtstart, :tz, :exdate
|
4
|
+
|
5
|
+
def initialize(rrule, dtstart: Time.now, tzid: 'UTC', exdate: [])
|
6
|
+
@rrule = rrule
|
7
|
+
# This removes all sub-second and floors it to the second level.
|
8
|
+
# Sub-second level calculations breaks a lot of assumptions in this
|
9
|
+
# library and rounding it may also cause unexpected inequalities.
|
10
|
+
@dtstart = Time.at(dtstart.to_i).in_time_zone(tzid)
|
11
|
+
@tz = tzid
|
12
|
+
@exdate = exdate
|
13
|
+
end
|
14
|
+
|
15
|
+
def all
|
16
|
+
reject_exdates(all_until(nil))
|
17
|
+
end
|
18
|
+
|
19
|
+
def between(start_date, end_date)
|
20
|
+
# This removes all sub-second and floors it to the second level.
|
21
|
+
# Sub-second level calculations breaks a lot of assumptions in this
|
22
|
+
# library and rounding it may also cause unexpected inequalities.
|
23
|
+
floored_start_date = Time.at(start_date.to_i)
|
24
|
+
floored_end_date = Time.at(end_date.to_i)
|
25
|
+
reject_exdates(all_until(floored_end_date).reject { |instance| instance < floored_start_date })
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def reject_exdates(results)
|
31
|
+
results.reject { |date| exdate.include?(date) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def all_until(end_date)
|
35
|
+
result = []
|
36
|
+
|
37
|
+
context = Context.new(options, dtstart, tz)
|
38
|
+
context.rebuild(dtstart.year, dtstart.month)
|
39
|
+
|
40
|
+
timeset = options[:timeset]
|
41
|
+
total = 0
|
42
|
+
count = options[:count]
|
43
|
+
|
44
|
+
filters = []
|
45
|
+
|
46
|
+
frequency = case options[:freq]
|
47
|
+
when 'DAILY'
|
48
|
+
Daily.new(context)
|
49
|
+
when 'WEEKLY'
|
50
|
+
Weekly.new(context)
|
51
|
+
when 'MONTHLY'
|
52
|
+
Monthly.new(context)
|
53
|
+
when 'YEARLY'
|
54
|
+
Yearly.new(context)
|
55
|
+
end
|
56
|
+
|
57
|
+
if options[:bymonth]
|
58
|
+
filters.push(ByMonth.new(options[:bymonth], context))
|
59
|
+
end
|
60
|
+
|
61
|
+
if options[:byweekno]
|
62
|
+
filters.push(ByWeekNumber.new(options[:byweekno], context))
|
63
|
+
end
|
64
|
+
|
65
|
+
if options[:byweekday]
|
66
|
+
filters.push(ByWeekDay.new(options[:byweekday], context))
|
67
|
+
end
|
68
|
+
|
69
|
+
if options[:byyearday]
|
70
|
+
filters.push(ByYearDay.new(options[:byyearday], context))
|
71
|
+
end
|
72
|
+
|
73
|
+
if options[:bymonthday]
|
74
|
+
filters.push(ByMonthDay.new(options[:bymonthday], context))
|
75
|
+
end
|
76
|
+
|
77
|
+
if options[:bysetpos]
|
78
|
+
generator = BySetPosition.new(options[:bysetpos], context)
|
79
|
+
else
|
80
|
+
generator = AllOccurrences.new(context)
|
81
|
+
end
|
82
|
+
|
83
|
+
loop do
|
84
|
+
return result if frequency.current_date.year > MAX_YEAR
|
85
|
+
|
86
|
+
possible_days_of_year = frequency.possible_days
|
87
|
+
|
88
|
+
possible_days_of_year.each_with_index do |day_index, i|
|
89
|
+
possible_days_of_year[i] = nil if filters.any? { |filter| filter.reject?(day_index) }
|
90
|
+
end
|
91
|
+
|
92
|
+
results_with_time = generator.combine_dates_and_times(possible_days_of_year, timeset)
|
93
|
+
results_with_time.sort.each do |this_result|
|
94
|
+
if end_date
|
95
|
+
if this_result > end_date
|
96
|
+
return result
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
if options[:until]
|
101
|
+
if this_result > options[:until]
|
102
|
+
return result
|
103
|
+
end
|
104
|
+
result.push(this_result)
|
105
|
+
elsif this_result >= dtstart
|
106
|
+
total += 1
|
107
|
+
if options[:count]
|
108
|
+
count -= 1
|
109
|
+
result.push(this_result)
|
110
|
+
return result if count == 0
|
111
|
+
else
|
112
|
+
result.push(this_result)
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
frequency.advance
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def options
|
122
|
+
@options ||= parse_options
|
123
|
+
end
|
124
|
+
|
125
|
+
def parse_options
|
126
|
+
options = { interval: 1, wkst: 1 }
|
127
|
+
|
128
|
+
params = @rrule.split(';')
|
129
|
+
params.each do |param|
|
130
|
+
option, value = param.split('=')
|
131
|
+
|
132
|
+
case option
|
133
|
+
when 'FREQ'
|
134
|
+
options[:freq] = value
|
135
|
+
when 'COUNT'
|
136
|
+
options[:count] = value.to_i
|
137
|
+
when 'UNTIL'
|
138
|
+
options[:until] = Time.parse(value)
|
139
|
+
when 'INTERVAL'
|
140
|
+
options[:interval] = value.to_i
|
141
|
+
when 'BYDAY'
|
142
|
+
options[:byweekday] = value.split(',').map { |day| Weekday.parse(day) }
|
143
|
+
when 'BYSETPOS'
|
144
|
+
options[:bysetpos] = value.split(',').map(&:to_i)
|
145
|
+
when 'WKST'
|
146
|
+
options[:wkst] = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'].index(value)
|
147
|
+
when 'BYMONTH'
|
148
|
+
options[:bymonth] = value.split(',').compact.map(&:to_i)
|
149
|
+
when 'BYMONTHDAY'
|
150
|
+
options[:bymonthday] = value.split(',').map(&:to_i)
|
151
|
+
when 'BYWEEKNO'
|
152
|
+
options[:byweekno] = value.split(',').map(&:to_i)
|
153
|
+
when 'BYYEARDAY'
|
154
|
+
options[:byyearday] = value.split(',').map(&:to_i)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
if !(options[:byweekno] || options[:byyearday] || options[:bymonthday] || options[:byweekday])
|
159
|
+
case options[:freq]
|
160
|
+
when 'YEARLY'
|
161
|
+
unless options[:bymonth]
|
162
|
+
options[:bymonth] = [dtstart.month]
|
163
|
+
end
|
164
|
+
options[:bymonthday] = [dtstart.day]
|
165
|
+
when 'MONTHLY'
|
166
|
+
options[:bymonthday] = [dtstart.day]
|
167
|
+
when 'WEEKLY'
|
168
|
+
options[:byweekday] = [Weekday.new(dtstart.wday)]
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
unless options[:byweekday].nil?
|
173
|
+
options[:byweekday], options[:bynweekday] = options[:byweekday].partition { |wday| wday.ordinal.nil? }
|
174
|
+
end
|
175
|
+
|
176
|
+
options[:timeset] = [{ hour: dtstart.hour, minute: dtstart.min, second: dtstart.sec }]
|
177
|
+
|
178
|
+
options
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|