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.
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