temporals 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
data.tar.gz.sig ADDED
Binary file
data/History.txt ADDED
@@ -0,0 +1,13 @@
1
+ === 2.0.0 / 2009-11-21
2
+
3
+ * Moved gem to gemcutter.org
4
+ * Added several examples and specs
5
+ * Made them all work properly
6
+ * The parsing engine is a bit smarter and more flexible
7
+
8
+ === 1.0.0 / 2009-08-21
9
+
10
+ * 1 major enhancement
11
+
12
+ * Initial release
13
+
data/Manifest.txt ADDED
@@ -0,0 +1,10 @@
1
+ History.txt
2
+ lib/temporals.rb
3
+ lib/temporals/parser.rb
4
+ lib/temporals/patterns.rb
5
+ lib/temporals/ruby_ext.rb
6
+ lib/temporals/types.rb
7
+ Manifest.txt
8
+ Rakefile
9
+ README.txt
10
+ spec/temporals_spec.rb
data/README.txt ADDED
@@ -0,0 +1,98 @@
1
+ = temporals
2
+
3
+ * Homepage: http://dcparker.github.com/temporals
4
+ * Code: http://github.com/dcparker/temporals
5
+
6
+ == DESCRIPTION:
7
+
8
+ "We could develop some interpreter that would be able to parse and process a range of expressions that we might want to deal with. This would be quite flexible, but also pretty hard" (Martin Fowler, http://martinfowler.com/apsupp/recurring.pdf). Temporals is a Ruby parser for just that.
9
+
10
+ == FEATURES:
11
+
12
+ Temporals can parse the following expressions:
13
+
14
+ * "2-4pm every thursday"
15
+ * "2:30-3p every mon and wed and 3-3:30 on friday"
16
+ * "Thursdays in 2009 and 9 January 2009"
17
+ * "Third Wednesday and Thursday Aug at 3pm"
18
+ * "2pm Fridays in January 2009 and Thursdays in 2009"
19
+ * "1st Thursdays at 4-5pm and First - Fourth of March at 2-3:30pm"
20
+ * "1st-2nd and last Thursdays of March and April 5-6:30pm and March 16th - 24th at 2-2:30pm"
21
+ * ...and probably more that you are going to waste your time thinking of.
22
+
23
+ Temporals is NOT GUARANTEED to parse anything you give it. It has a limited, though rather large and flexible, vocabulary.
24
+ If you come across something that doesn't parse properly, please write a spec test for it and send it to me: gems@behindlogic.com.
25
+
26
+ == PROBLEMS:
27
+
28
+ * Should add support for "4th of the month" or "4th month"
29
+
30
+ == SYNOPSIS:
31
+
32
+ Temporals works to follow the true spirit of the TimePoint pattern -- the idea that every expression of time in fact has a certain level of intended precision. For example, if I say "March 5, 2000", I simply mean any time within that day -- all day. However, if I say "2:05pm March 5, 2000", I mean any second within that specific minute. But if I say "2:00pm Fridays" I really mean every Friday, and that expression is precise to the day-of-week and to the hour and minute, but the second, week, month, or year don't matter.
33
+ The main usage of Temporals, as it currently has been built for, is Temporals.parse and Temporals#include?. Here are several examples:
34
+
35
+ Example Expressions:
36
+ # All day Tuesday, every week
37
+ t1 = Temporals.parse('Tuesday')
38
+
39
+ # Every Feb 24, all day long
40
+ t2 = Temporals.parse("February 24th")
41
+
42
+ # Feb 24 in 2001, all day long
43
+ t3 = Temporals.parse("24 February 2001")
44
+
45
+ # every Thursday in 2009, and the 9th of January 2009 too
46
+ t4 = Temporals.parse("9 January 2009 and Thursday 2009")
47
+
48
+ # default duration of ONE of the most specific piece mentioned: 2-3pm every
49
+ # friday in January of '09, and also all day every thursday all year in 2009
50
+ t5 = Temporals.parse("2pm Fridays in January 2009 and Thursdays in 2009")
51
+
52
+ # first thursday of every month, forever, from 4 to 5 pm;
53
+ # also 2 to 3:30 pm on the 1st, 2nd, 3rd, and 4th of March (every year!)
54
+ t6 = Temporals.parse("1st Thursdays at 4-5pm and 1st - 4th of March at 2pm")
55
+
56
+ # you can figure this one out for yourself... Then figure out how the parsing knows exactly what this means! :P
57
+ t7 = Temporals.parse("1st-2nd and 4th Thursdays of March and April 5-6:30pm and March 16th - 24th at 2-2:30pm")
58
+
59
+ More methods available (referencing some of the above examples):
60
+ t5.include?(Time.parse('2009-02-05 19:00:00')) => true
61
+ t5.include?(Time.parse('2009-01-09 2:31pm')) => true
62
+ t5.include?(Time.parse('February 5, 2010')) => false
63
+ t5.include?(Time.parse('January 16, 2007')) => false
64
+ t6.occurrances_on_day(Time.parse('2009-04-02')) => [{:start_time=>Thu Apr 02 16:00:00 2009, :end_time=>Thu Apr 02 17:00:00 2009}, {:start_time=>Thu Apr 02 14:00:00 2009, :end_time=>Thu Apr 02 15:30:00 2009}]
65
+ t7.occurs_on_day?(Time.parse('2009-03-05')) => true
66
+
67
+ == REQUIREMENTS:
68
+
69
+ * Just Ruby!
70
+
71
+ == INSTALL
72
+
73
+ * gem install temporals -s http://gemcutter.org
74
+
75
+ ## License ##
76
+
77
+ (The MIT License)
78
+
79
+ Copyright (c) 2009 BehindLogic <gems@behindlogic.com>
80
+
81
+ Permission is hereby granted, free of charge, to any person obtaining
82
+ a copy of this software and associated documentation files (the
83
+ 'Software'), to deal in the Software without restriction, including
84
+ without limitation the rights to use, copy, modify, merge, publish,
85
+ distribute, sublicense, and/or sell copies of the Software, and to
86
+ permit persons to whom the Software is furnished to do so, subject to
87
+ the following conditions:
88
+
89
+ The above copyright notice and this permission notice shall be
90
+ included in all copies or substantial portions of the Software.
91
+
92
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
93
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
94
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
95
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
96
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
97
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
98
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # -*- ruby -*-
2
+
3
+ require 'rubygems'
4
+ require 'hoe'
5
+
6
+ Hoe.spec 'temporals' do
7
+ developer 'Daniel Parker', 'gems@behindlogic.com'
8
+ self.url = "http://dcparker.github.com/temporals"
9
+ end
data/lib/temporals.rb ADDED
@@ -0,0 +1,116 @@
1
+
2
+ require 'date'
3
+ require 'time'
4
+ require 'temporals/ruby_ext'
5
+ require 'temporals/types'
6
+ require 'temporals/patterns'
7
+ require 'temporals/parser'
8
+
9
+ class Temporal
10
+ VERSION = '2.0.0'
11
+
12
+ def initialize(options)
13
+ options.each do |key,value|
14
+ instance_variable_set(:"@#{key}", value)
15
+ end
16
+ end
17
+
18
+ def [](key)
19
+ instance_variable_get(:"@#{key}")
20
+ end
21
+
22
+ def start_pm?
23
+ if @start_time =~ /([ap])m$/ || @end_time =~ /([ap])m$/
24
+ $1 == 'p'
25
+ else
26
+ nil
27
+ end
28
+ end
29
+
30
+ def include?(datetime)
31
+ return false unless occurs_on_day?(datetime)
32
+ if @type =~ /timerange/
33
+ test_date = datetime.strftime("%Y-%m-%d")
34
+ test_start_time = Time.parse("#{test_date} #{@start_time.gsub(/([ap]m)$/,'')}#{start_pm? ? 'pm' : 'am'}")
35
+ test_end_time = Time.parse("#{test_date} #{@end_time}")
36
+ test_end_time = test_end_time+59 if test_end_time == test_start_time # If they're equal, they are assumed to be to the minute precision
37
+ puts "TimeRange: date:#{test_date} test_start:#{test_start_time} test_end:#{test_end_time} <=> #{datetime}" if $DEBUG
38
+ return false unless datetime.between?(test_start_time, test_end_time)
39
+ end
40
+ return true
41
+ puts "#{datetime} Included!" if $DEBUG
42
+ end
43
+
44
+ def occurs_on_day?(datetime)
45
+ puts "#{datetime} IN? #{inspect}" if $DEBUG
46
+ if @type =~ /month/
47
+ puts "Month #{Month.order[datetime.month-1].inspect} == #{@month.inspect} >> #{Month.order[datetime.month-1].value_in?(@month)}" if $DEBUG
48
+ return false unless Month.order[datetime.month-1].value_in?(@month)
49
+ end
50
+ if @type =~ /ord_wday/
51
+ puts "Weekday: #{WDay.order[datetime.wday].inspect} in? #{@wday.inspect} == #{WDay.order[datetime.wday].value_in?(@wday)}" if $DEBUG
52
+ return false unless WDay.order[datetime.wday].value_in?(@wday)
53
+ puts "WeekdayOrd: #{datetime.wday_ord} in? #{@ord.inspect} == #{datetime.wday_ord.value_in?(@ord)}" if $DEBUG
54
+ puts "WeekdayLast: #{datetime.wday_last} in? #{@ord.inspect} == #{datetime.wday_last.value_in?(@ord)}" if $DEBUG
55
+ return false unless datetime.wday_ord.value_in?(@ord) || datetime.wday_last.value_in?(@ord)
56
+ end
57
+ if @type =~ /month_ord/
58
+ puts "Day #{datetime.day} == #{@ord.inspect} >> #{datetime.day.value_in?(@ord)}" if $DEBUG
59
+ return false unless datetime.day.value_in?(@ord)
60
+ end
61
+ if @type =~ /year/
62
+ puts "Year #{datetime.year} == #{@year.inspect} >> #{datetime.year.value_in?(@year)}" if $DEBUG
63
+ return false unless datetime.year.value_in?(@year)
64
+ end
65
+ if @type =~ /wday/
66
+ puts "Weekday: #{WDay.order[datetime.wday].inspect} in? #{@wday.inspect} == #{WDay.order[datetime.wday].value_in?(@wday)}" if $DEBUG
67
+ return false unless WDay.order[datetime.wday].value_in?(@wday)
68
+ end
69
+ puts "Occurs on #{datetime}!" if $DEBUG
70
+ return true
71
+ end
72
+
73
+ def occurrances_on_day(date)
74
+ occurs_on_day?(date) ? [{:start_time => start_time(date), :end_time => end_time(date)}] : []
75
+ end
76
+
77
+ def start_time(date=nil)
78
+ if date
79
+ @start_time.sub!(/^(\d+)/,'\1:00') if @start_time =~ /^(\d+)[^:]/
80
+ puts "#{date.strftime("%Y-%m-%d")} #{@start_time}" if $DEBUG
81
+ Time.parse("#{date.strftime("%Y-%m-%d")} #{@start_time}")
82
+ else
83
+ @start_time
84
+ end
85
+ end
86
+ def end_time(date=nil)
87
+ if date
88
+ @end_time.sub!(/^(\d+)/,'\1:00') if @end_time =~ /^(\d+)[^:]/
89
+ puts "#{date.strftime("%Y-%m-%d")} #{@end_time}" if $DEBUG
90
+ Time.parse("#{date.strftime("%Y-%m-%d")} #{@end_time}")
91
+ else
92
+ @end_time
93
+ end
94
+ end
95
+
96
+ def to_natural
97
+ @type.split(/_/).collect {|w|
98
+ case w
99
+ # when 'ord'
100
+ # if @ord.respond_to?(:to_natural)
101
+ # @ord.to_natural('ord')
102
+ # end
103
+ # if @ord.is_a?(Range)
104
+ # @ord.begin + 'th-' + @ord.end
105
+ # elsif @ord.is_a?(Array)
106
+ #
107
+ # else
108
+ # @ord + 'th'
109
+ # end
110
+ when 'dummy'
111
+ else
112
+ instance_variable_get('@'+w)
113
+ end
114
+ }.join(' ')
115
+ end
116
+ end
@@ -0,0 +1,140 @@
1
+ class Temporal
2
+ class << self
3
+ def parse(expression)
4
+ puts "Parsing expression: #{expression.inspect}" if $DEBUG
5
+ # 1. Normalize the expression
6
+ # TODO: re-create normalize: ' -&| ', 'time-time'
7
+ expression.gsub!(/\s+/,' ').gsub!(/([\-\&\|])/,' \1 ')
8
+ expression.gsub!(/(#{TimeRegexp}?) +- +(#{TimeRegexp})/,'\1-\2')
9
+ expression.gsub!(/in ([09]\d|\d{4})/) {|s|
10
+ y = $1
11
+ y.length == 2 ? (y =~ /^0/ ? '20'+y : '19'+y) : y
12
+ }
13
+ expression.gsub!(/(^| )(#{TimeRegexp})( |$)/i) {|s|
14
+ b = $1
15
+ time = $2
16
+ a = $3
17
+ if s =~ /[:m]/ # If it really looks like a lone piece of time, it'll have either a am/pm or a ':' in it.
18
+ # Converting a floating time into a timerange that spans the appropriate duration
19
+ puts "Converting Time to TimeRange: #{time.inspect}" if $DEBUG
20
+ # Figure out what precision we're at
21
+ newtime = time + '-'
22
+ if time =~ /(\d+):(\d+)([ap]m?|$)?/
23
+ end_hr = $1.to_i
24
+ end_mn = $2.to_i + 1
25
+ if end_mn > 59
26
+ end_mn -= 60
27
+ end_hr += 1
28
+ end
29
+ end_hr -= 12 if end_hr > 12
30
+ newtime += "#{end_hr}:#{end_mn}#{$3}" # end-time is 1 minute later
31
+ elsif time =~ /(\d+)([ap]m?|$)?/
32
+ end_hr = $1.to_i + 1
33
+ end_hr -= 12 if end_hr > 12
34
+ newtime += "#{end_hr}#{$2}" # end-time is 1 hour later
35
+ end
36
+ puts "Converted! #{newtime}" if $DEBUG
37
+ b+newtime+a
38
+ else
39
+ s
40
+ end
41
+ }
42
+ puts "Normalized expression: #{expression.inspect}" if $DEBUG
43
+
44
+ # 2. Analyze the expression
45
+ words = expression.split(/\s+/)
46
+ puts words.inspect if $DEBUG
47
+ analyzed_expression = words.inject([]) do |a,word|
48
+ a << case word
49
+ when WordTypes[:ord]
50
+ {:type => 'ord', :ord => $1}
51
+ when WordTypes[:word_ord]
52
+ ord = WordOrds.include?(word.downcase) ? WordOrds.index(word.downcase)+1 : 'last'
53
+ puts "WordOrd: #{ord}" if $DEBUG
54
+ {:type => 'ord', :ord => ord}
55
+ when WordTypes[:wday]
56
+ {:type => 'wday', :wday => WDay.normalize($1)}
57
+ when WordTypes[:year]
58
+ {:type => 'year', :year => word}
59
+ when WordTypes[:month]
60
+ {:type => 'month', :month => Month.normalize(word)}
61
+ when WordTypes[:union]
62
+ {:type => 'union'}
63
+ when WordTypes[:range]
64
+ {:type => 'range'}
65
+ when WordTypes[:timerange]
66
+ # determine and inject am/pm
67
+ start_at = $1
68
+ end_at = $2
69
+ start_at_p = $1 if start_at =~ /([ap])m?$/
70
+ end_at_p = $1 if end_at =~ /([ap])m?$/
71
+ start_hr = start_at.split(/:/)[0].to_i
72
+ start_hr = '0' if start_hr == '12' # this is used only for > & < comparisons, so converting it to 0 makes everything easier.
73
+ end_hr = end_at.split(/:/)[0].to_i
74
+ if start_at_p && !end_at_p
75
+ # If end-time is a lower hour number than start-time, then we've crossed noon or midnight, and the end-time a/pm should be opposite.
76
+ end_at = end_at + (start_hr <= end_hr ? start_at_p : (start_at_p=='a' ? 'p' : 'a'))
77
+ elsif end_at_p && !start_at_p
78
+ # If end-time is a lower hour number than start-time, then we've crossed noon or midnight, and the start-time a/pm should be opposite.
79
+ start_at = start_at + (start_hr <= end_hr ? end_at_p : (end_at_p=='a' ? 'p' : 'a'))
80
+ elsif !end_at_p && !start_at_p
81
+ # If neither had am/pm attached, assume am if after 7, pm if 12 or before 7.
82
+ start_at_p = (start_hr < 8 ? 'p' : 'a')
83
+ start_at = start_at + start_at_p
84
+ # If end-time is a lower hour number than start-time, then we've crossed noon or midnight, and the end-time a/pm should be opposite.
85
+ end_at = end_at + (start_hr <= end_hr ? start_at_p : (start_at_p=='a' ? 'p' : 'a'))
86
+ end
87
+ start_at += 'm' unless start_at =~ /m$/
88
+ end_at += 'm' unless end_at =~ /m$/
89
+ {:type => 'timerange', :start_time => start_at, :end_time => end_at}
90
+ end
91
+ end.compact
92
+ def analyzed_expression.collect_types
93
+ collect {|e|
94
+ puts "E: #{e.inspect}" if $DEBUG
95
+ e[:type]
96
+ }
97
+ end
98
+
99
+ # 3. Combine common patterns
100
+ puts analyzed_expression.inspect if $DEBUG
101
+ puts analyzed_expression.collect_types.inspect if $DEBUG
102
+
103
+ something_was_modified = true
104
+ while something_was_modified
105
+ something_was_modified = false
106
+ before_length = analyzed_expression.length
107
+ CommonPatterns.each do |pattern|
108
+ while i = analyzed_expression.collect_types.includes_sequence?(pattern.split(/ /))
109
+ CommonPatternActions[pattern].call(analyzed_expression,i)
110
+ end
111
+ end
112
+ after_length = analyzed_expression.length
113
+ something_was_modified = true if before_length != after_length
114
+ end
115
+
116
+ puts analyzed_expression.inspect if $DEBUG
117
+ puts analyzed_expression.collect_types.inspect if $DEBUG
118
+
119
+ # What remains should be simply sections of boolean logic
120
+ # 4. Parse boolean logic
121
+ analyzed_expression.each_index do |i|
122
+ analyzed_expression[i] = Temporal.new(analyzed_expression[i]) unless analyzed_expression[i][:type].in?('union', 'range')
123
+ end
124
+
125
+ BooleanPatterns.each do |pattern|
126
+ while i = analyzed_expression.collect_types.includes_sequence?(pattern.split(/ /))
127
+ BooleanPatternActions[pattern].call(analyzed_expression,i)
128
+ break if analyzed_expression.length == 1
129
+ end
130
+ end
131
+
132
+ # This is how we know if the expression couldn't quite be figured out. It should have been condensed down to a single Temporal or Temporal::Set
133
+ if analyzed_expression.length > 1
134
+ raise RuntimeError, "Could not parse Temporal Expression: check to make sure it is clear and has only one possible meaning."
135
+ end
136
+
137
+ return analyzed_expression[0]
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,167 @@
1
+ class Temporal
2
+ TimeRegexp = '\d{1,2}(?::\d\d)?(?::\d{1,2})?(?:[ap]m?)?'
3
+ WordOrds = %w(first second third fourth fifth)
4
+ WordTypes = {
5
+ :ord => /^([1-3]?\d)(?:st|nd|rd|th)?$/i, # Should be able to distinguish
6
+ :word_ord => /^(first|second|third|fourth|fifth|last)$/i,
7
+ :wday => /^(#{(WDay.order + WDay.translations.keys).join('|')})s?$/i,
8
+ :month => /^#{(Month.order + Month.translations.keys).join('|')}$/i,
9
+ :year => /^([09]\d|\d{4})$/, # A year will be either 2 digits starting with a 9 or a 0, or 4 digits.
10
+ :union => /^(?:and)$/i,
11
+ :range => /^(?:-|to|through)$/i,
12
+ :timerange => /^(#{TimeRegexp}?)-(#{TimeRegexp})$/i,
13
+ # :from => /^from$/i,
14
+ # :to => /^to$/i,
15
+ # :between => /^between$/i
16
+ }
17
+
18
+ # These are in a specific order
19
+ CommonPatterns = [
20
+ 'ord range ord',
21
+ 'ord union ord',
22
+ 'year range year',
23
+ 'year union year',
24
+ 'wday range wday',
25
+ 'wday union wday',
26
+ # 'timerange union timerange', # Not quite figured out yet, I'd need to implement new code into the question methods.
27
+ 'month ord',
28
+ 'ord month',
29
+ 'ord wday',
30
+ 'month_ord timerange',
31
+ 'month union month',
32
+ 'month range month',
33
+ 'ord_wday month',
34
+ 'ord_wday timerange',
35
+ 'ord_wday_month timerange',
36
+ 'timerange wday',
37
+ 'month_ord year',
38
+ 'month year',
39
+ 'wday year',
40
+ "wday_timerange month",
41
+ "wday_timerange year",
42
+ "wday_timerange month_year"
43
+ ]
44
+ CommonPatternActions = {
45
+ 'ord range ord' => lambda {|words,i|
46
+ words[i][:ord] = (words[i][:ord].to_i..words[i+2][:ord].to_i)
47
+ words.slice!(i+1,2)
48
+ },
49
+ 'ord union ord' => lambda {|words,i|
50
+ words[i][:ord] = ArrayOfRanges.new(words[i][:ord], words[i+2][:ord])
51
+ words.slice!(i+1,2)
52
+ },
53
+ 'year range year' => lambda {|words,i|
54
+ words[i][:year] = (words[i][:year].to_i..words[i+2][:year].to_i)
55
+ words.slice!(i+1,2)
56
+ },
57
+ 'year union year' => lambda {|words,i|
58
+ words[i][:year] = ArrayOfRanges.new(words[i][:year], words[i+2][:year])
59
+ words.slice!(i+1,2)
60
+ },
61
+ 'wday range wday' => lambda {|words,i|
62
+ words[i][:wday] = (words[i][:wday].to_i..words[i+2][:wday].to_i)
63
+ words.slice!(i+1,2)
64
+ },
65
+ 'wday union wday' => lambda {|words,i|
66
+ words[i][:wday] = ArrayOfRanges.new(words[i][:wday], words[i+2][:wday])
67
+ words.slice!(i+1,2)
68
+ },
69
+ # 'timerange union timerange' => lambda {|words,i|
70
+ # words[i][:wday] = ArrayOfRanges.new(words[i][:wday], words[i+2][:wday])
71
+ # words.slice!(i+1,2)
72
+ # },
73
+ 'month ord' => lambda {|words,i|
74
+ words[i][:type] = 'month_ord'
75
+ words[i][:ord] = words[i+1][:ord]
76
+ words.slice!(i+1,1)
77
+ },
78
+ 'ord month' => lambda {|words,i|
79
+ words[i][:type] = 'month_ord'
80
+ words[i][:month] = words[i+1][:month]
81
+ words.slice!(i+1,1)
82
+ },
83
+ 'ord wday' => lambda {|words,i|
84
+ words[i][:type] = 'ord_wday'
85
+ words[i][:wday] = words[i+1][:wday]
86
+ words.slice!(i+1,1)
87
+ },
88
+ 'month_ord timerange' => lambda {|words,i|
89
+ words[i][:type] = 'month_ord_timerange'
90
+ words[i][:start_time] = words[i+1][:start_time]
91
+ words[i][:end_time] = words[i+1][:end_time]
92
+ words.slice!(i+1,1)
93
+ },
94
+ 'month union month' => lambda {|words,i|
95
+ words[i][:month] = ArrayOfRanges.new(words[i][:month], words[i+2][:month])
96
+ words.slice!(i+1,2)
97
+ },
98
+ 'month range month' => lambda {|words,i|
99
+ raise "Not Implemented Yet!"
100
+ },
101
+ 'ord_wday month' => lambda {|words,i|
102
+ words[i][:type] = 'ord_wday_month'
103
+ words[i][:month] = words[i+1][:month]
104
+ words.slice!(i+1,1)
105
+ },
106
+ 'ord_wday timerange' => lambda {|words,i|
107
+ words[i][:type] = 'ord_wday_timerange'
108
+ words[i][:start_time] = words[i+1][:start_time]
109
+ words[i][:end_time] = words[i+1][:end_time]
110
+ words.slice!(i+1,1)
111
+ },
112
+ 'timerange wday' => lambda {|words,i|
113
+ words[i][:type] = 'wday_timerange'
114
+ words[i][:wday] = words[i+1][:wday]
115
+ words.slice!(i+1,1)
116
+ },
117
+ 'ord_wday_month timerange' => lambda {|words,i|
118
+ words[i][:type] = 'ord_wday_month_timerange'
119
+ words[i][:start_time] = words[i+1][:start_time]
120
+ words[i][:end_time] = words[i+1][:end_time]
121
+ words.slice!(i+1,1)
122
+ },
123
+ 'month_ord year' => lambda {|words,i|
124
+ words[i][:type] = 'ord_month_year'
125
+ words[i][:year] = words[i+1][:year]
126
+ words.slice!(i+1,1)
127
+ },
128
+ 'wday year' => lambda {|words,i|
129
+ words[i][:type] = 'wday_year'
130
+ words[i][:year] = words[i+1][:year]
131
+ words.slice!(i+1,1)
132
+ },
133
+ 'month year' => lambda {|words,i|
134
+ words[i][:type] = 'month_year'
135
+ words[i][:year] = words[i+1][:year]
136
+ words.slice!(i+1,1)
137
+ },
138
+ 'wday_timerange month' => lambda {|words,i|
139
+ words[i][:type] = 'wday_timerange_month'
140
+ words[i][:month] = words[i+1][:month]
141
+ words.slice!(i+1,1)
142
+ },
143
+ 'wday_timerange year' => lambda {|words,i|
144
+ words[i][:type] = 'wday_timerange_year'
145
+ words[i][:year] = words[i+1][:year]
146
+ words.slice!(i+1,1)
147
+ },
148
+ 'wday_timerange month_year' => lambda {|words,i|
149
+ words[i][:type] = 'wday_timerange_month_year'
150
+ words[i][:month] = words[i+1][:month]
151
+ words[i][:year] = words[i+1][:year]
152
+ words.slice!(i+1,1)
153
+ }
154
+ }
155
+
156
+ BooleanPatterns = [
157
+ 'union'
158
+ ]
159
+
160
+ BooleanPatternActions = {
161
+ 'union' => lambda {|words,i|
162
+ words[i-1] = Temporal::Union.new(words[i-1], words[i+1])
163
+ words.slice!(i,2)
164
+ puts "Boolean-connected: " + words.inspect if $DEBUG
165
+ }
166
+ }
167
+ end
@@ -0,0 +1,59 @@
1
+ class Array
2
+ def includes_sequence?(sequence)
3
+ first_exists = []
4
+ each_index do |i|
5
+ first_exists << i if self[i] == sequence[0]
6
+ end
7
+ first_exists.any? do |i|
8
+ return i if self[i...(i+sequence.length)] == sequence
9
+ end
10
+ end
11
+
12
+ def include_value?(v)
13
+ any? do |iv|
14
+ case iv
15
+ when Range || Array
16
+ v.to_i.in?(iv)
17
+ else
18
+ if iv.to_s =~ /^\d+$/ && v.to_s =~ /^\d+$/
19
+ iv.to_i == v.to_i
20
+ else
21
+ iv.to_s == v.to_s
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class Range
29
+ alias :include_value? :include?
30
+ end
31
+
32
+ class Object
33
+ def value_in?(*arg)
34
+ arg = arg[0] if arg.length == 1 && arg[0].respond_to?(:include_value?)
35
+ arg.include_value?(self)
36
+ end
37
+ def in?(*arg)
38
+ arg = arg[0] if arg.length == 1 && arg[0].respond_to?(:include?)
39
+ arg.include?(self)
40
+ end
41
+ def my_methods
42
+ methods.sort - Object.methods
43
+ end
44
+ end
45
+
46
+ module WdayOrd
47
+ def wday_ord
48
+ (day.to_f / 7).ceil
49
+ end
50
+ def wday_last
51
+ 'last' if day.to_i + 7 > Date.new(year, month, -1).day
52
+ end
53
+ end
54
+ class DateTime
55
+ include WdayOrd
56
+ end
57
+ class Time
58
+ include WdayOrd
59
+ end
@@ -0,0 +1,125 @@
1
+ class Temporal
2
+ class ArrayOfRanges < Array
3
+ def self.new(*values)
4
+ n = allocate
5
+ n.push(*values)
6
+ n
7
+ end
8
+ end
9
+
10
+ class Classification
11
+ class << self
12
+ attr_reader :order, :translations
13
+
14
+ def abbreviations
15
+ @abbreviations ||= translations.inject({}) do |h,(k,v)|
16
+ h[v] = k unless h.has_key?(v) && h[v].length < k.length
17
+ h
18
+ end
19
+ end
20
+
21
+ def normalize(word)
22
+ word = word.capitalize
23
+ order.include?(word) ? word : (translations.has_key?(word) ? translations[word] : nil)
24
+ end
25
+ end
26
+ end
27
+
28
+ class WDay < Classification
29
+ @order = %w(Sunday Monday Tuesday Wednesday Thursday Friday)
30
+ @translations = {
31
+ 'S' => 'Sunday',
32
+ 'M' => 'Monday',
33
+ 'T' => 'Tuesday',
34
+ 'W' => 'Wednesday',
35
+ 'Th' => 'Thursday',
36
+ 'F' => 'Friday',
37
+ 'Sa' => 'Saturday',
38
+ 'Sun' => 'Sunday',
39
+ 'Mo' => 'Monday',
40
+ 'Mon' => 'Monday',
41
+ 'Tu' => 'Tuesday',
42
+ 'Tue' => 'Tuesday',
43
+ 'Tues' => 'Tuesday',
44
+ 'Wed' => 'Wednesday',
45
+ 'Thu' => 'Thursday',
46
+ 'Thur' => 'Thursday',
47
+ 'Thurs' => 'Thursday',
48
+ 'Fri' => 'Friday',
49
+ 'Sat' => 'Saturday'
50
+ }
51
+ end
52
+ class Month < Classification
53
+ @order = %w(January February March April May June July August September October November December)
54
+ @translations = {
55
+ 'Jan' => 'January',
56
+ 'Feb' => 'February',
57
+ 'Mar' => 'March',
58
+ 'Apr' => 'April',
59
+ 'Jun' => 'June',
60
+ 'Jul' => 'July',
61
+ 'Aug' => 'August',
62
+ 'Sep' => 'September',
63
+ 'Oct' => 'October',
64
+ 'Nov' => 'November',
65
+ 'Dec' => 'December'
66
+ }
67
+ end
68
+
69
+ class Set
70
+ def set
71
+ @set ||= []
72
+ end
73
+
74
+ def [](key)
75
+ instance_variable_get(:"@#{key}")
76
+ end
77
+
78
+ def initialize(*args)
79
+ # @set = args.select {|e| e.is_a?(Temporal) || e.is_a?(Range)}
80
+ @set = args.select {|e| e.is_a?(Temporal)}
81
+ end
82
+ end
83
+ class Union < Set
84
+ def initialize(*args)
85
+ @type = 'UNION'
86
+ super
87
+ end
88
+
89
+ def include?(other)
90
+ set.any? {|tp| tp.include?(other)}
91
+ end
92
+
93
+ def occurs_on_day?(other)
94
+ set.any? {|tp| tp.occurs_on_day?(other)}
95
+ end
96
+
97
+ def occurrances_on_day(other)
98
+ set.inject([]) {|a,tp| a.concat(tp.occurrances_on_day(other)); a}
99
+ end
100
+
101
+ def eql?(other)
102
+ if other.is_a?(Temporal::Union)
103
+ set.length == other.length && set.length.times { |i| return false unless set[i].eql? other[i] }
104
+ else
105
+ # what else can we compare to?
106
+ raise "Comparison of Temporal::Union with something different (#{other.class.name})."
107
+ end
108
+ end
109
+
110
+ def to_natural
111
+ set.inject([]) {|a,tp| a << tp.to_natural}.join(' and ')
112
+ end
113
+
114
+ private
115
+ # Sends all other methods to the set array.
116
+ def method_missing(name, *args, &block)
117
+ if set.respond_to?(name)
118
+ args << block if block_given?
119
+ set.send(name, *args)
120
+ else
121
+ super
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,103 @@
1
+ require 'rubygems'
2
+ require 'spec'
3
+ require File.dirname(__FILE__) + '/../lib/temporals'
4
+
5
+ describe Temporal do
6
+ it "1st-2nd and last Thursdays of March and April 5-6:30pm and March 16th - 24th at 2-2:30pm" do
7
+ t = Temporal.parse("1st-2nd and last Thursdays of March and April 5-6:30pm and March 16th - 24th at 2-2:30pm")
8
+ t.include?(Time.parse('2009-03-05 17:54')).should eql(true)
9
+ t.include?(Time.parse('2009-03-05 18:24')).should eql(true)
10
+ t.include?(Time.parse('2009-03-05 18:30')).should eql(true)
11
+ t.include?(Time.parse('2009-03-05 18:31')).should eql(false)
12
+ t.include?(Time.parse('2009-04-26 18:31')).should eql(false)
13
+ t.occurs_on_day?(Time.parse('2009-03-04')).should eql(false)
14
+ t.occurs_on_day?(Time.parse('2009-03-11')).should eql(false)
15
+ t.occurs_on_day?(Time.parse('2009-03-25')).should eql(false)
16
+ t.occurs_on_day?(Time.parse('2009-04-01')).should eql(false)
17
+ t.occurs_on_day?(Time.parse('2009-04-08')).should eql(false)
18
+ t.occurs_on_day?(Time.parse('2009-04-22')).should eql(false)
19
+ t.occurs_on_day?(Time.parse('2009-03-05')).should eql(true)
20
+ t.occurs_on_day?(Time.parse('2009-03-12')).should eql(true)
21
+ t.occurs_on_day?(Time.parse('2009-03-26')).should eql(true)
22
+ t.occurs_on_day?(Time.parse('2009-04-02')).should eql(true)
23
+ t.occurs_on_day?(Time.parse('2009-04-09')).should eql(true)
24
+ t.occurs_on_day?(Time.parse('2009-04-30')).should eql(true)
25
+ t.occurs_on_day?(Time.parse('2009-03-06')).should eql(false)
26
+ t.occurs_on_day?(Time.parse('2009-03-13')).should eql(false)
27
+ t.occurs_on_day?(Time.parse('2009-03-27')).should eql(false)
28
+ t.occurs_on_day?(Time.parse('2009-04-03')).should eql(false)
29
+ t.occurs_on_day?(Time.parse('2009-04-10')).should eql(false)
30
+ t.occurs_on_day?(Time.parse('2009-04-24')).should eql(false)
31
+ t.occurrances_on_day(Time.parse('2009-04-23')).length.should eql(0)
32
+ t.occurrances_on_day(Time.parse('2009-04-30')).length.should eql(1)
33
+ end
34
+
35
+ it "1st Thursdays at 4-5pm and First - Fourth of March at 2-3:30pm" do
36
+ t = Temporal.parse("1st Tuesdays at 4-5pm and First - Fourth of March at 2-3:30pm")
37
+ t.occurs_on_day?(Time.parse('2009-04-07')).should eql(true)
38
+ t.occurrances_on_day(Time.parse('2009-03-01')).length.should eql(1)
39
+ t.occurrances_on_day(Time.parse('2009-03-03')).length.should eql(2)
40
+ t.occurrances_on_day(Time.parse('2009-05-05')).length.should eql(1)
41
+ t.occurrances_on_day(Time.parse('2009-05-12')).length.should eql(0)
42
+ end
43
+
44
+ it "should FAIL on 1st Thursdays at 4-5pm and First - Fourth of March and April at 2-3:30pm" do
45
+ lambda {
46
+ # This could be grouped in two different ways. The first is more likely what is meant, but
47
+ # the second is possible so the patterns are not set up to match the first option for sure.
48
+ # (1st Thursdays at 4-5pm) and ((First - Fourth of March and April) at 2-3:30pm)
49
+ # (1st Thursdays at 4-5pm) and (First - Fourth of March) and (April at 2-3:30pm)
50
+ Temporal.parse("1st Thursdays at 4-5pm and First - Fourth of March and April at 2-3:30pm")
51
+ }.should raise_error(RuntimeError, "Could not parse Temporal Expression: check to make sure it is clear and has only one possible meaning.")
52
+ end
53
+
54
+ it "2pm Tuesdays" do
55
+ t = Temporal.parse("2pm Tuesdays")
56
+ t.occurs_on_day?(Time.parse('2009-04-28')).should eql(true)
57
+ t.occurrances_on_day(Time.parse('2009-04-28')).length.should eql(1)
58
+ t.occurrances_on_day(Time.parse('2009-04-28'))[0][:start_time].should eql(Time.parse('2009-04-28 2:00pm'))
59
+ t.occurrances_on_day(Time.parse('2009-04-28'))[0][:end_time].should eql(Time.parse('2009-04-28 3pm'))
60
+ t.include?(Time.parse('2009-04-21 14:52')).should eql(true)
61
+ t.include?(Time.parse('2009-04-21 14:59:59')).should eql(true)
62
+ end
63
+
64
+ it "2:30pm Tuesdays" do
65
+ t = Temporal.parse("2:30pm Tuesdays")
66
+ t.occurs_on_day?(Time.parse('2009-04-28')).should eql(true)
67
+ t.occurrances_on_day(Time.parse('2009-04-28')).length.should eql(1)
68
+ t.occurrances_on_day(Time.parse('2009-04-28'))[0][:start_time].should eql(Time.parse('2009-04-28 2:30pm'))
69
+ t.occurrances_on_day(Time.parse('2009-04-28'))[0][:end_time].should eql(Time.parse('2009-04-28 2:31pm'))
70
+ t.include?(Time.parse('2009-04-21 14:30')).should eql(true)
71
+ t.include?(Time.parse('2009-04-21 14:30:59')).should eql(true)
72
+ t.include?(Time.parse('2009-04-21 14:31')).should eql(true)
73
+ t.include?(Time.parse('2009-04-21 14:31:01')).should eql(false)
74
+ end
75
+
76
+ it "2:30-3p every mon and wed and 3-3:30 on friday" do
77
+ t = Temporal.parse("2:30-3p every mon and wed and 3-3:30 on friday")
78
+ t.occurs_on_day?(Time.parse('2009-11-20')).should eql(true)
79
+ t.occurs_on_day?(Time.parse('2009-11-19')).should eql(false)
80
+ t.occurs_on_day?(Time.parse('2009-11-18')).should eql(true)
81
+ t.occurrances_on_day(Time.parse('2009-11-20'))[0][:start_time].should eql(Time.parse('2009-11-20 3pm'))
82
+ t.occurrances_on_day(Time.parse('2009-11-18'))[0][:end_time].should eql(Time.parse('2009-11-18 3pm'))
83
+ t.include?(Time.parse('2009-11-20 3:14pm')).should eql(true)
84
+ end
85
+
86
+ it "Thursdays in 2009 and 9 January 2009" do
87
+ t = Temporal.parse("Thursdays in 2009 and 9 January 2009")
88
+ t.occurs_on_day?(Time.parse('January 9, 2009')).should eql(true)
89
+ t.occurs_on_day?(Time.parse('November 19, 2009')).should eql(true)
90
+ t.occurs_on_day?(Time.parse('October 19, 2009')).should eql(false)
91
+ t.include?(Time.parse('2009-01-08 3:14pm')).should eql(true)
92
+ end
93
+
94
+ it "2pm Fridays in January 2009 and Thursdays in 2009" do
95
+ t = Temporal.parse("2pm Fridays in January 2009 and Thursdays in 2009")
96
+ t.occurs_on_day?(Time.parse('November 19, 2009')).should eql(true)
97
+ t.occurs_on_day?(Time.parse('January 9, 2009')).should eql(true)
98
+ t.occurs_on_day?(Time.parse('October 19, 2009')).should eql(false)
99
+ t.include?(Time.parse('2009-01-08 3:14pm')).should eql(true)
100
+ t.include?(Time.parse('2009-01-09 2:14pm')).should eql(true)
101
+ t.include?(Time.parse('2009-01-09 3:14pm')).should eql(false)
102
+ end
103
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: temporals
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Daniel Parker
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain:
11
+ - |
12
+ -----BEGIN CERTIFICATE-----
13
+ MIIDNjCCAh6gAwIBAgIBADANBgkqhkiG9w0BAQUFADBBMQ0wCwYDVQQDDARnZW1z
14
+ MRswGQYKCZImiZPyLGQBGRYLYmVoaW5kbG9naWMxEzARBgoJkiaJk/IsZAEZFgNj
15
+ b20wHhcNMDkxMTE5MDQ0NzIzWhcNMTAxMTE5MDQ0NzIzWjBBMQ0wCwYDVQQDDARn
16
+ ZW1zMRswGQYKCZImiZPyLGQBGRYLYmVoaW5kbG9naWMxEzARBgoJkiaJk/IsZAEZ
17
+ FgNjb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDs+2PvSoBC5mCz
18
+ Nm95RXKOb/u+CmPA70DoG9cF0zipCie003Vm0DYL3Rtobcr32eMvHxkWoJ6xoz0I
19
+ h74yNKtHjTTCxj86HWBPsE6xVMxVCftClndjCyKsiMiqvvp1wDNO0FFK+6LijmL3
20
+ 2Xkp4brWq1JO92y9vYct34R7o2X//+nwZs+sss+EYhNdvdUJfWy7tA5dghGdLvRn
21
+ UhJJSAtTefkBCwO7bufLEt+n7wIRbiJJ5dDCwE3NIX4wUSrNeYwXGXA/Ybki+BUl
22
+ 3KJF9IC0XR9fY9DGF0FXBKrkfDlZRrnQOem2aIxeuln0KQLJXXJuDTQPHO+mK3EG
23
+ UPhR7IAHAgMBAAGjOTA3MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgSwMB0GA1UdDgQW
24
+ BBQn32ZStKmqwFqs2vuglYzkvDzBZjANBgkqhkiG9w0BAQUFAAOCAQEAe3Z+iMmy
25
+ IX9ChQW4hNNb1HOpgCMc2RL+vUwku9WE95dZ+BE4A6mOYTj5JXdYf4R4Z2vavr+d
26
+ nwJWtXPeIBWxireb8gUU0DwqodYTpsmkj5LD1zIaZ59rXlwDA9O0V4fwE1iRG5MD
27
+ mB7m8fT8WNOeg4AfjH4aSiHI1+HX1RQkc7KFdLotKnCevzYU6Jza5VUbXyJ+yCEH
28
+ DFARN3mkfGI+18MRDEi39nK2O/bBd6Wf0cYPEKsGQjNNAIBtv9belepSMd1KKfQ2
29
+ L7j8CnNDCrsHDe7/251D85wSvTH4Q/41NE5ahdCkkHwzDJeyhXpmNuUSswdn7woz
30
+ teST6sOe8lUhZQ==
31
+ -----END CERTIFICATE-----
32
+
33
+ date: 2009-11-21 00:00:00 -05:00
34
+ default_executable:
35
+ dependencies:
36
+ - !ruby/object:Gem::Dependency
37
+ name: hoe
38
+ type: :development
39
+ version_requirement:
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 2.3.3
45
+ version:
46
+ description: "\"We could develop some interpreter that would be able to parse and process a range of expressions that we might want to deal with. This would be quite flexible, but also pretty hard\" (Martin Fowler, http://martinfowler.com/apsupp/recurring.pdf). Temporals is a Ruby parser for just that."
47
+ email:
48
+ - gems@behindlogic.com
49
+ executables: []
50
+
51
+ extensions: []
52
+
53
+ extra_rdoc_files:
54
+ - History.txt
55
+ - Manifest.txt
56
+ - README.txt
57
+ files:
58
+ - History.txt
59
+ - lib/temporals.rb
60
+ - lib/temporals/parser.rb
61
+ - lib/temporals/patterns.rb
62
+ - lib/temporals/ruby_ext.rb
63
+ - lib/temporals/types.rb
64
+ - Manifest.txt
65
+ - Rakefile
66
+ - README.txt
67
+ - spec/temporals_spec.rb
68
+ has_rdoc: true
69
+ homepage: http://dcparker.github.com/temporals
70
+ licenses: []
71
+
72
+ post_install_message:
73
+ rdoc_options:
74
+ - --main
75
+ - README.txt
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: "0"
83
+ version:
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: "0"
89
+ version:
90
+ requirements: []
91
+
92
+ rubyforge_project: temporals
93
+ rubygems_version: 1.3.5
94
+ signing_key:
95
+ specification_version: 3
96
+ summary: "\"We could develop some interpreter that would be able to parse and process a range of expressions that we might want to deal with"
97
+ test_files: []
98
+
metadata.gz.sig ADDED
Binary file