cyclical 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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