rrule 0.0.0 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -0
  3. data/.travis.yml +3 -0
  4. data/CHANGELOG.md +7 -0
  5. data/CONTRIBUTING.md +17 -0
  6. data/Gemfile +5 -0
  7. data/LICENSE.txt +202 -0
  8. data/README.md +71 -0
  9. data/Rakefile +30 -0
  10. data/lib/rrule.rb +26 -4
  11. data/lib/rrule/context.rb +137 -0
  12. data/lib/rrule/filters/by_month.rb +16 -0
  13. data/lib/rrule/filters/by_month_day.rb +16 -0
  14. data/lib/rrule/filters/by_week_day.rb +24 -0
  15. data/lib/rrule/filters/by_week_number.rb +16 -0
  16. data/lib/rrule/filters/by_year_day.rb +18 -0
  17. data/lib/rrule/frequencies/daily.rb +13 -0
  18. data/lib/rrule/frequencies/frequency.rb +34 -0
  19. data/lib/rrule/frequencies/monthly.rb +14 -0
  20. data/lib/rrule/frequencies/weekly.rb +29 -0
  21. data/lib/rrule/frequencies/yearly.rb +14 -0
  22. data/lib/rrule/generators/all_occurrences.rb +26 -0
  23. data/lib/rrule/generators/by_set_position.rb +35 -0
  24. data/lib/rrule/rule.rb +181 -0
  25. data/lib/rrule/weekday.rb +17 -0
  26. data/rrule.gemspec +20 -0
  27. data/spec/context_spec.rb +261 -0
  28. data/spec/filters/by_month_day_spec.rb +35 -0
  29. data/spec/filters/by_month_spec.rb +35 -0
  30. data/spec/filters/by_week_day_spec.rb +35 -0
  31. data/spec/filters/by_week_number_spec.rb +41 -0
  32. data/spec/filters/by_year_day_spec.rb +35 -0
  33. data/spec/frequencies/daily_spec.rb +55 -0
  34. data/spec/frequencies/monthly_spec.rb +57 -0
  35. data/spec/frequencies/weekly_spec.rb +57 -0
  36. data/spec/frequencies/yearly_spec.rb +52 -0
  37. data/spec/generators/all_occurrences_spec.rb +44 -0
  38. data/spec/generators/by_set_position_spec.rb +39 -0
  39. data/spec/rule_spec.rb +1988 -0
  40. data/spec/spec_helper.rb +21 -0
  41. data/spec/weekday_spec.rb +34 -0
  42. 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,13 @@
1
+ module RRule
2
+ class Daily < Frequency
3
+ def possible_days
4
+ [current_date.yday - 1] # convert to 0-indexed
5
+ end
6
+
7
+ private
8
+
9
+ def advance_by
10
+ { days: context.options[:interval] }
11
+ end
12
+ end
13
+ 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,14 @@
1
+ module RRule
2
+ class Yearly < Frequency
3
+ def possible_days
4
+ (0...context.year_length_in_days).to_a
5
+ end
6
+
7
+ private
8
+
9
+ def advance_by
10
+ { years: context.options[:interval] }
11
+ end
12
+ end
13
+ end
14
+
@@ -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
@@ -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