cyclical 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.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+
19
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in cyclical.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2011 Viktor Charypar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Cyclical
2
+
3
+ Recurring events library for ruby calendar applications.
4
+
5
+ ## About
6
+
7
+ Cyclical lets you list recurring events with complex recurrence rules like "every 4 years, the first Tuesday after a Monday in November" in a simple way. The API is inspired by [ice_cube](https://github.com/seejohnrun/ice_cube) and uses method chaining for natural rule specification.
8
+
9
+ You can find out if a given time matches the schedule, list event occurrences or add event duration and list suboccurrences in a given interval, which is handy when you need to trim event occurences to the interval (like rendering a day in a week view of a calendar with events crossing midnight).
10
+
11
+ Cyclical was originally extracted from a browser based calendar application and is written in ruby. There is a [JavaScript implementation of Cyclical](https://github.com/charypar/cyclical-js) supporting the same features, intended as a front-end counterpart. You can pass data between the implementations using the built-in JSON serialization.
12
+
13
+ ### Missing features and TODO
14
+
15
+ * Rule exception dates
16
+ * Hourly and secondly rules
17
+
18
+ ## Install
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ gem 'cyclical'
23
+
24
+ And then execute:
25
+
26
+ $ bundle
27
+
28
+ Or install it yourself as:
29
+
30
+ $ gem install cyclical
31
+
32
+ ## Usage
33
+
34
+ The central thing in Cyclical is the ```Schedule```. Let's take the example of U.S. Presidential Election day from RFC 5545:
35
+
36
+ ```ruby
37
+ include Cyclical
38
+
39
+ date = Time.local(1997, 8, 2, 9, 0, 0)
40
+ schedule = Schedule.new date, Rule.yearly(4).month(11).weekday(:tue).monthdays(2, 3, 4, 5, 6, 7, 8)
41
+
42
+ election_dates = schedule.first(3);
43
+ ```
44
+
45
+ ### Creating schedules
46
+
47
+ Each schedule has a base ```date``` and a recurrence rule. The four supported rules are:
48
+
49
+ * daily
50
+ * weekly
51
+ * monthly
52
+ * yearly
53
+
54
+ with corresponding factory methods on ```Rule```. The factory methods take a single argument - the repetition interval.
55
+
56
+ The basic recurrence rule matches the original date, i.e. for a yearly rule, the occurences will always happen on the same date. To specify a more complex pattern, you can use filters.
57
+
58
+ Filters replace the single value (day, month) with a set of values that match. For example, instead of only matching the day of month in of the base date, with the ```monthdays``` filter, you can match multiple month days.
59
+
60
+ Available filters are:
61
+
62
+ * weekday(s)
63
+ * monthday(s)
64
+ * yearday(s)
65
+ * month(s)
66
+
67
+ Each filter methord takes variable arguments containing integers or string (incl. shortcuts) for a given date component.
68
+
69
+ You can limit the schedule either by a number of events (using the ```count``` method) or an end date (using the ```stop``` method).
70
+
71
+ ### Querying occurrences and suboccurrences
72
+
73
+ TODO. See ```lib/cyclical/schedule.rb```
74
+
75
+ ### Serialization and deserialization
76
+
77
+ TODO. See ```lib/cyclical/schedule.rb```
78
+
79
+ ### More examples
80
+
81
+ TODO. See RFC 5545 examples in ```spec/schedule_spec.rb```
82
+
83
+ ## License
84
+
85
+ Cyclical is released under the [MIT License](http://www.opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/cyclical.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'cyclical/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "cyclical"
8
+ gem.version = Cyclical::VERSION
9
+ gem.authors = ["Viktor Charypar"]
10
+ gem.email = ["charypar@gmail.com"]
11
+ gem.description = %q{Cyclical lets you list recurring events with complex recurrence rules like "every 4 years, the first Tuesday after a Monday in November" in a simple way.}
12
+ gem.summary = %q{Recurring events library for calendar applications.}
13
+ gem.homepage = "http://github.com/charypar/cyclical"
14
+ gem.license = 'MIT'
15
+
16
+ gem.files = `git ls-files`.split($/)
17
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
+ gem.require_paths = ["lib"]
20
+
21
+ gem.add_runtime_dependency 'active_support'
22
+
23
+ gem.add_development_dependency 'rspec'
24
+ end
@@ -0,0 +1,38 @@
1
+ module Cyclical
2
+ class MonthdaysFilter
3
+
4
+ attr_reader :monthdays
5
+
6
+ def initialize(*monthdays)
7
+ raise ArgumentError, "Specify at least one day of the month" if monthdays.empty?
8
+
9
+ @monthdays = monthdays.sort
10
+ end
11
+
12
+ def match?(date)
13
+ last = date.end_of_month.day
14
+ (@monthdays.include?(date.day) || @monthdays.include?(date.day - last - 1))
15
+ end
16
+
17
+ def step
18
+ 1.day
19
+ end
20
+
21
+ # FIXME - this can probably be calculated
22
+ def next(date)
23
+ until match?(date)
24
+ date += 1.day
25
+ end
26
+
27
+ date
28
+ end
29
+
30
+ def previous(date)
31
+ until match?(date)
32
+ date -= 1.day
33
+ end
34
+
35
+ date
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,55 @@
1
+ module Cyclical
2
+ class MonthsFilter
3
+
4
+ MONTH_NAMES = {
5
+ :jan => 1, :january => 1,
6
+ :feb => 2, :february => 2,
7
+ :mar => 3, :march => 3,
8
+ :apr => 4, :april => 4,
9
+ :may => 5,
10
+ :jun => 6, :june => 6,
11
+ :jul => 7, :july => 7,
12
+ :aug => 8, :august => 8,
13
+ :sep => 9, :sept => 9, :september => 9,
14
+ :oct => 10, :october => 10,
15
+ :nov => 11, :november => 11,
16
+ :dec => 12, :december => 12
17
+ }
18
+
19
+ attr_reader :months
20
+
21
+ def initialize(*months)
22
+ raise ArgumentError, "Specify at least one month" if months.empty?
23
+
24
+ @months = months.map { |m| m.is_a?(Integer) ? m : MONTH_NAMES[m.to_sym] }.sort
25
+ end
26
+
27
+ def match?(date)
28
+ @months.include?(date.mon)
29
+ end
30
+
31
+ def step
32
+ 1.month
33
+ end
34
+
35
+ def next(date)
36
+ return date if match?(date)
37
+
38
+ if month = @months.find { |m| m > date.month }
39
+ date.beginning_of_year + (month - 1).months + date.hour.hours + date.min.minutes + date.sec.seconds
40
+ else
41
+ date.beginning_of_year + 1.year + (@months.first - 1).months + date.hour.hours + date.min.minutes + date.sec.seconds
42
+ end
43
+ end
44
+
45
+ def previous(date)
46
+ return date if match?(date)
47
+
48
+ if month = @months.reverse.find { |m| m < date.month }
49
+ date.beginning_of_year + month.months - 1.day + date.hour.hours + date.min.minutes + date.sec.seconds
50
+ else
51
+ date.beginning_of_year - 1.year + @months.last.months - 1.day + date.hour.hours + date.min.minutes + date.sec.seconds
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,90 @@
1
+ module Cyclical
2
+ class WeekdaysFilter
3
+
4
+ WEEKDAYS = {
5
+ :su => 0, :sun => 0, :sunday => 0,
6
+ :mo => 1, :mon => 1, :monday => 1,
7
+ :tu => 2, :tue => 2, :tuesday => 2,
8
+ :we => 3, :wed => 3, :wednesday => 3,
9
+ :th => 4, :thu => 4, :thursday => 4,
10
+ :fr => 5, :fri => 5, :friday => 5,
11
+ :sa => 6, :sat => 6, :saturday => 6
12
+ }
13
+
14
+ WEEKDAY_NAMES = [:su, :mo, :tu, :we, :th, :fr, :sa]
15
+
16
+ attr_reader :weekdays, :ordered_weekdays, :rule
17
+
18
+ def initialize(*weekdays)
19
+ @rule = weekdays.shift if weekdays.first.is_a?(Rule)
20
+
21
+ raise ArgumentError, "Specify at least one weekday" if weekdays.empty?
22
+ @ordered_weekdays = {}
23
+
24
+ if weekdays.last.respond_to?(:has_key?)
25
+ raise ArgumentError, "No recurrence rule given for ordered weekdays filter" if @rule.nil?
26
+
27
+ weekdays.last.each do |day, orders|
28
+ day = day.is_a?(Integer) ? day : WEEKDAYS[day]
29
+ orders = [orders] unless orders.respond_to?(:each)
30
+
31
+ @ordered_weekdays[WEEKDAY_NAMES[day]] = orders.sort
32
+ end
33
+ weekdays = weekdays[0..-2]
34
+ end
35
+
36
+ @weekdays = weekdays.map { |w| w.is_a?(Integer) ? w : WEEKDAYS[w.to_sym] }.sort
37
+ end
38
+
39
+ def match?(date)
40
+ return true if weekdays.include?(date.wday)
41
+
42
+ day = WEEKDAY_NAMES[date.wday]
43
+ return false if ordered_weekdays[day].nil?
44
+
45
+ first, occ, max = order_in_interval(date)
46
+
47
+ return (ordered_weekdays[day].include?(occ) || ordered_weekdays[day].include?(occ - max - 1))
48
+ end
49
+
50
+ def step
51
+ 1.day
52
+ end
53
+
54
+ # FIXME - this can probably be calculated
55
+ def next(date)
56
+ until match?(date)
57
+ date += 1.day
58
+ end
59
+
60
+ date
61
+ end
62
+
63
+ def previous(date)
64
+ until match?(date)
65
+ date -= 1.day
66
+ end
67
+
68
+ date
69
+ end
70
+
71
+ private
72
+
73
+ def order_in_interval(date)
74
+ case @rule
75
+ when YearlyRule
76
+ first = (7 + date.wday - date.beginning_of_year.wday) % 7 + 1
77
+ occ = (date.yday - first) / 7 + 1
78
+ max = (date.end_of_year.yday - first) / 7 + 1
79
+ when MonthlyRule
80
+ first = (7 + date.wday - date.beginning_of_month.wday) % 7 + 1
81
+ occ = (date.day - first) / 7 + 1
82
+ max = (date.end_of_month.day - first) / 7 + 1
83
+ else
84
+ raise RuntimeError, "Ordered weekdays filter only supports monthy and yearly rules. (#{@rule.class} given)"
85
+ end
86
+
87
+ [first, occ, max]
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,38 @@
1
+ module Cyclical
2
+ class YeardaysFilter
3
+
4
+ attr_reader :yeardays
5
+
6
+ def initialize(*yeardays)
7
+ raise ArgumentError, "Specify at least one day of the month" if yeardays.empty?
8
+
9
+ @yeardays = yeardays.sort
10
+ end
11
+
12
+ def match?(date)
13
+ last = date.end_of_year.yday
14
+ (@yeardays.include?(date.yday) || @yeardays.include?(date.yday - last - 1))
15
+ end
16
+
17
+ def step
18
+ 1.day
19
+ end
20
+
21
+ # FIXME - traverse the days directly
22
+ def next(date)
23
+ until match?(date)
24
+ date += 1.day
25
+ end
26
+
27
+ date
28
+ end
29
+
30
+ def previous(date)
31
+ until match?(date)
32
+ date -= 1.day
33
+ end
34
+
35
+ date
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,121 @@
1
+ require 'cyclical/suboccurrence'
2
+
3
+ module Cyclical
4
+ # Holds an occurence of a recurrence rule, can compute next and previous and list occurrences
5
+ class Occurrence
6
+
7
+ attr_reader :rule, :start_time
8
+ attr_accessor :duration
9
+
10
+ def initialize(rule, start_time)
11
+ @rule = rule
12
+ @start_time = @rule.match?(start_time, start_time) ? start_time : @rule.next(start_time, start_time)
13
+ end
14
+
15
+ def next_occurrence(after)
16
+ next_occurrences(1, after).first
17
+ end
18
+
19
+ def next_occurrences(n, after)
20
+ return [] if @rule.stop && after > @rule.stop
21
+ time = (after <= @start_time ? @start_time : after)
22
+ time = @rule.next(time, @start_time) unless @rule.match?(time, @start_time)
23
+
24
+ list_occurrences(time) { (n -= 1) >= 0 }
25
+ end
26
+
27
+ def previous_occurrence(before)
28
+ previous_occurrences(1, before).first
29
+ end
30
+
31
+ def previous_occurrences(n, before)
32
+ return [] if before <= @start_time
33
+ time = (@rule.stop.nil? || before < @rule.stop ? before : @rule.stop)
34
+ time = @rule.previous(time, @start_time) # go back even if before matches the rule (half-open time intervals, remember?)
35
+
36
+ list_occurrences(time, :back) { (n -= 1) >= 0 }.reverse
37
+ end
38
+
39
+ def occurrences_between(t1, t2)
40
+ raise ArgumentError, "Empty time interval" unless t2 > t1
41
+ return [] if t2 <= @start_time || @rule.stop && t1 >= @rule.stop
42
+
43
+ time = (t1 <= @start_time ? @start_time : t1)
44
+ time = @rule.next(time, @start_time) unless @rule.match?(time, @start_time)
45
+
46
+ list_occurrences(time) { |t| t < t2 }
47
+ end
48
+
49
+ def suboccurrences_between(t1, t2)
50
+ occurrences = occurrences_between(t1 - duration, t2)
51
+ occurrences.map { |occ| Suboccurrence.find(:occurrence => (occ)..(occ + duration), :interval => t1..t2) }
52
+ end
53
+
54
+ def all
55
+ if @rule.stop
56
+ list_occurrences(@start_time) { |t| t < @rule.stop }
57
+ else
58
+ n = @rule.count
59
+ list_occurrences(@start_time) { (n -= 1) >= 0 }
60
+ end
61
+ end
62
+
63
+ def to_hash
64
+ @rule.to_hash
65
+ end
66
+
67
+ private
68
+
69
+ # yields valid occurrences, return false from the block to stop
70
+ def list_occurrences(from, direction = :forward, &block)
71
+ raise ArgumentError, "From #{from} not matching the rule #{@rule} and start time #{@start_time}" unless @rule.match?(from, @start_time)
72
+
73
+ results = []
74
+
75
+ n, current = init_loop(from, direction)
76
+ loop do
77
+ # Rails.logger.debug("Listing occurrences of #{@rule}, going #{direction.to_s}, current: #{current}")
78
+ # break on schedule span limits
79
+ return results unless (current >= @start_time) && (@rule.stop.nil? || current < @rule.stop) && (@rule.count.nil? || (n -= 1) >= 0)
80
+
81
+ # break on block condition
82
+ return results unless yield current
83
+
84
+ results << current
85
+
86
+ # step
87
+ if direction == :forward
88
+ current = @rule.next(current, @start_time)
89
+ else
90
+ current = @rule.previous(current, @start_time)
91
+ end
92
+ end
93
+ end
94
+
95
+ def init_loop(from, direction)
96
+ return 0, from unless @rule.count # without count limit, life is easy
97
+
98
+ # with it, it's... well...
99
+ if direction == :forward
100
+ n = 0
101
+ current = @start_time
102
+ while current < from
103
+ n += 1
104
+ current = @rule.next(current, @start_time)
105
+ end
106
+
107
+ # return the n remaining events
108
+ return (@rule.count - n), current
109
+ else
110
+ n = 0
111
+ current = @start_time
112
+ while current < from && (n += 1) < @rule.count
113
+ current = @rule.next(current, @start_time)
114
+ end
115
+
116
+ # return all events (downloop - yaay, I invented a word - will stop on start time)
117
+ return @rule.count, current
118
+ end
119
+ end
120
+ end
121
+ end