timeliness 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,221 @@
1
+ module Timeliness
2
+ module Formats
3
+
4
+ # Format tokens:
5
+ # y = year
6
+ # m = month
7
+ # d = day
8
+ # h = hour
9
+ # n = minute
10
+ # s = second
11
+ # u = micro-seconds
12
+ # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
13
+ # _ = optional space
14
+ # tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
15
+ # zo = Timezone offset (e.g. +10:00, -08:00, +1000)
16
+ #
17
+ # All other characters are considered literal. You can embed regexp in the
18
+ # format but no guarantees that it will remain intact. If you don't use capture
19
+ # groups, dots or backslashes in the regexp, it may well work as expected.
20
+ # For special characters, use POSIX character classes for safety.
21
+ #
22
+ # Repeating tokens:
23
+ # x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
24
+ # xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
25
+ #
26
+ # Special Cases:
27
+ # yy = 2 or 4 digit year
28
+ # yyyy = exactly 4 digit year
29
+ # mmm = month long name (e.g. 'Jul' or 'July')
30
+ # ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
31
+ # u = microseconds matches 1 to 6 digits
32
+
33
+ @time_formats = [
34
+ 'hh:nn:ss',
35
+ 'hh-nn-ss',
36
+ 'h:nn',
37
+ 'h.nn',
38
+ 'h nn',
39
+ 'h-nn',
40
+ 'h:nn_ampm',
41
+ 'h.nn_ampm',
42
+ 'h nn_ampm',
43
+ 'h-nn_ampm',
44
+ 'h_ampm'
45
+ ]
46
+
47
+ @date_formats = [
48
+ 'yyyy-mm-dd',
49
+ 'yyyy/mm/dd',
50
+ 'yyyy.mm.dd',
51
+ 'm/d/yy',
52
+ 'd/m/yy',
53
+ 'm\d\yy',
54
+ 'd\m\yy',
55
+ 'd-m-yy',
56
+ 'dd-mm-yyyy',
57
+ 'd.m.yy',
58
+ 'd mmm yy'
59
+ ]
60
+
61
+ @datetime_formats = [
62
+ 'yyyy-mm-dd hh:nn:ss.u',
63
+ 'yyyy-mm-dd hh:nn:ss',
64
+ 'yyyy-mm-dd h:nn',
65
+ 'yyyy-mm-dd h:nn_ampm',
66
+ 'm/d/yy h:nn:ss',
67
+ 'm/d/yy h:nn_ampm',
68
+ 'm/d/yy h:nn',
69
+ 'd/m/yy hh:nn:ss',
70
+ 'd/m/yy h:nn_ampm',
71
+ 'd/m/yy h:nn',
72
+ 'dd-mm-yyyy hh:nn:ss',
73
+ 'dd-mm-yyyy h:nn_ampm',
74
+ 'dd-mm-yyyy h:nn',
75
+ 'ddd, dd mmm yyyy hh:nn:ss tz', # RFC 822
76
+ 'ddd, dd mmm yyyy hh:nn:ss zo', # RFC 822
77
+ 'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
78
+ 'yyyy-mm-ddThh:nn:ssZ', # ISO 8601 without zone offset
79
+ 'yyyy-mm-ddThh:nn:sszo' # ISO 8601 with zone offset
80
+ ]
81
+
82
+ # All tokens available for format construction. The token array is made of
83
+ # regexp and key for format component mapping, if any.
84
+ #
85
+ @format_tokens = {
86
+ 'ddd' => [ '\w{3,9}' ],
87
+ 'dd' => [ '\d{2}', :day ],
88
+ 'd' => [ '\d{1,2}', :day ],
89
+ 'mmm' => [ '\w{3,9}', :month ],
90
+ 'mm' => [ '\d{2}', :month ],
91
+ 'm' => [ '\d{1,2}', :month ],
92
+ 'yyyy' => [ '\d{4}', :year ],
93
+ 'yy' => [ '\d{4}|\d{2}', :year ],
94
+ 'hh' => [ '\d{2}', :hour ],
95
+ 'h' => [ '\d{1,2}', :hour ],
96
+ 'nn' => [ '\d{2}', :min ],
97
+ 'n' => [ '\d{1,2}', :min ],
98
+ 'ss' => [ '\d{2}', :sec ],
99
+ 's' => [ '\d{1,2}', :sec ],
100
+ 'u' => [ '\d{1,6}', :usec ],
101
+ 'ampm' => [ '[aApP]\.?[mM]\.?', :meridian ],
102
+ 'zo' => [ '[+-]\d{2}:?\d{2}', :offset ],
103
+ 'tz' => [ '[A-Z]{1,4}' ],
104
+ '_' => [ '\s?' ]
105
+ }
106
+
107
+ # Component argument values will be passed to the format method if matched in
108
+ # the time string. The key should match the key defined in the format tokens.
109
+ #
110
+ # The array consists of the position the value should be inserted in
111
+ # the time array, and the code to place in the time array.
112
+ #
113
+ # If the position is nil, then the value won't be put in the time array. If the
114
+ # code is nil, then just the raw value is used.
115
+ #
116
+ @format_components = {
117
+ :year => [ 0, 'unambiguous_year(year)'],
118
+ :month => [ 1, 'month_index(month)'],
119
+ :day => [ 2 ],
120
+ :hour => [ 3, 'full_hour(hour, meridian ||= nil)'],
121
+ :min => [ 4 ],
122
+ :sec => [ 5 ],
123
+ :usec => [ 6, 'microseconds(usec)'],
124
+ :offset => [ 7, 'offset_in_seconds(offset)'],
125
+ :meridian => [ nil ]
126
+ }
127
+
128
+ US_FORMAT_REGEXP = /\Am{1,2}[^m]/
129
+
130
+ class << self
131
+ attr_accessor :time_formats, :date_formats, :datetime_formats, :format_tokens, :format_components
132
+ attr_reader :date_format_set, :time_format_set, :datetime_format_set
133
+
134
+ # Adds new formats. Must specify format type and can specify a :before
135
+ # option to nominate which format the new formats should be inserted in
136
+ # front on to take higher precedence.
137
+ #
138
+ # Error is raised if format already exists or if :before format is not found.
139
+ #
140
+ def add_formats(type, *add_formats)
141
+ formats = send("#{type}_formats")
142
+ options = add_formats.last.is_a?(Hash) ? add_formats.pop : {}
143
+ before = options[:before]
144
+ raise "Format for :before option #{format} was not found." if before && !formats.include?(before)
145
+
146
+ add_formats.each do |format|
147
+ raise "Format #{format} is already included in #{type} formats" if formats.include?(format)
148
+
149
+ index = before ? formats.index(before) : -1
150
+ formats.insert(index, format)
151
+ end
152
+ compile_formats
153
+ end
154
+
155
+ # Delete formats of specified type. Error raised if format not found.
156
+ #
157
+ def remove_formats(type, *remove_formats)
158
+ remove_formats.each do |format|
159
+ unless send("#{type}_formats").delete(format)
160
+ raise "Format #{format} not found in #{type} formats"
161
+ end
162
+ end
163
+ compile_formats
164
+ end
165
+
166
+ # Removes US date formats so that ambigious dates are parsed as European format
167
+ #
168
+ def use_euro_formats
169
+ @date_format_set = FormatSet.compile(date_formats.select { |format| US_FORMAT_REGEXP !~ format })
170
+ @datetime_format_set = FormatSet.compile(datetime_formats.select { |format| US_FORMAT_REGEXP !~ format })
171
+ end
172
+
173
+ # Restores default to parse ambiguous dates as US format
174
+ #
175
+ def use_us_formats
176
+ @date_format_set = FormatSet.compile(date_formats)
177
+ @datetime_format_set = FormatSet.compile(datetime_formats)
178
+ end
179
+
180
+ def compile_formats
181
+ @sorted_token_keys = nil
182
+ @time_format_set = FormatSet.compile(time_formats)
183
+ @date_format_set = FormatSet.compile(date_formats)
184
+ @datetime_format_set = FormatSet.compile(datetime_formats)
185
+ end
186
+
187
+ def sorted_token_keys
188
+ @sorted_token_keys ||= format_tokens.keys.sort {|a,b| a.size <=> b.size }.reverse
189
+ end
190
+
191
+ # Returns format for type and other possible matching format set based on type
192
+ # and value length. Gives minor speed-up by checking string length.
193
+ def format_set(type, string)
194
+ case type
195
+ when :date
196
+ [ @date_format_set, @datetime_format_set ]
197
+ when :time
198
+ if string.length < 11
199
+ [ @time_format_set ]
200
+ else
201
+ [ @datetime_format_set, @time_format_set ]
202
+ end
203
+ when :datetime
204
+ if string.length < 11
205
+ [ @date_format_set, @datetime_format_set ]
206
+ else
207
+ [ @datetime_format_set, @date_format_set ]
208
+ end
209
+ else
210
+ if string.length < 11
211
+ [ @date_format_set, @time_format_set, @datetime_format_set ]
212
+ else
213
+ [ @datetime_format_set, @date_format_set, @time_format_set ]
214
+ end
215
+ end
216
+ end
217
+
218
+ end
219
+
220
+ end
221
+ end
@@ -0,0 +1,49 @@
1
+ module Timeliness
2
+ module Helpers
3
+
4
+ def full_hour(hour, meridian)
5
+ hour = hour.to_i
6
+ return hour if meridian.nil?
7
+ if meridian.delete('.').downcase == 'am'
8
+ raise if hour == 0 || hour > 12
9
+ hour == 12 ? 0 : hour
10
+ else
11
+ hour == 12 ? hour : hour + 12
12
+ end
13
+ end
14
+
15
+ def unambiguous_year(year)
16
+ if year.length <= 2
17
+ century = Time.now.year.to_s[0..1].to_i
18
+ century -= 1 if year.to_i >= Timeliness.ambiguous_year_threshold
19
+ year = "#{century}#{year.rjust(2,'0')}"
20
+ end
21
+ year.to_i
22
+ end
23
+
24
+ def month_index(month)
25
+ return month.to_i if month.to_i > 0
26
+ month.length > 3 ? month_names.index(month.capitalize) : abbr_month_names.index(month.capitalize)
27
+ end
28
+
29
+ def month_names
30
+ defined?(I18n) ? I18n.t('date.month_names') : Date::MONTHNAMES
31
+ end
32
+
33
+ def abbr_month_names
34
+ defined?(I18n) ? I18n.t('date.abbr_month_names') : Date::ABBR_MONTHNAMES
35
+ end
36
+
37
+ def microseconds(usec)
38
+ (".#{usec}".to_f * 1_000_000).to_i
39
+ end
40
+
41
+ def offset_in_seconds(offset)
42
+ sign = offset =~ /^-/ ? -1 : 1
43
+ parts = offset.scan(/\d\d/).map {|p| p.to_f }
44
+ parts[1] = parts[1].to_f / 60
45
+ (parts[0] + parts[1]) * sign * 3600
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,94 @@
1
+ module Timeliness
2
+ module Parser
3
+
4
+ class << self
5
+
6
+ def parse(value, *args)
7
+ return value unless value.is_a?(String)
8
+
9
+ options = args.last.is_a?(Hash) ? args.pop : {}
10
+ type = args.first
11
+
12
+ time_array = _parse(value, type, options)
13
+ return nil if time_array.nil?
14
+
15
+ if type == :date
16
+ time_array[3..7] = nil
17
+ elsif type == :time
18
+ time_array[0..2] = current_date(options)
19
+ elsif type.nil?
20
+ dummy_date = current_date(options)
21
+ time_array[0] ||= dummy_date[0]
22
+ time_array[1] ||= dummy_date[1]
23
+ time_array[2] ||= dummy_date[2]
24
+ end
25
+ make_time(time_array[0..6], options[:zone])
26
+ end
27
+
28
+ def make_time(time_array, zone=nil)
29
+ return nil unless fast_date_valid_with_fallback(*time_array[0..2])
30
+
31
+ zone ||= Timeliness.default_timezone
32
+ case zone
33
+ when :utc, :local
34
+ time_with_datetime_fallback(zone, *time_array.compact)
35
+ when :current
36
+ Time.zone.local(*time_array)
37
+ else
38
+ Time.use_zone(zone) { Time.zone.local(*time_array) }
39
+ end
40
+ rescue ArgumentError, TypeError
41
+ nil
42
+ end
43
+
44
+ def _parse(string, type=nil, options={})
45
+ if options[:strict] && type
46
+ set = Formats.send("#{type}_format_set")
47
+ set.match(string, options[:format])
48
+ else
49
+ values = nil
50
+ Formats.format_set(type, string).find {|set| values = set.match(string, options[:format]) }
51
+ values
52
+ end
53
+ rescue
54
+ nil
55
+ end
56
+
57
+ private
58
+
59
+ def current_date(options)
60
+ now = if options[:now]
61
+ options[:now]
62
+ elsif options[:zone]
63
+ case options[:zone]
64
+ when :utc, :local
65
+ Time.now.send("get#{options[:zone]}")
66
+ when :current
67
+ Time.current
68
+ else
69
+ Time.use_zone(options[:zone]) { Time.current }
70
+ end
71
+ end
72
+ now ||= Timeliness.date_for_time_type
73
+ now.is_a?(Array) ? now[0..2] : Array(now).reverse[4..6]
74
+ end
75
+
76
+ # Taken from ActiveSupport and simplified
77
+ def time_with_datetime_fallback(utc_or_local, year, month=1, day=1, hour=0, min=0, sec=0, usec=0)
78
+ return nil if hour > 23 || min > 59 || sec > 59
79
+ ::Time.send(utc_or_local, year, month, day, hour, min, sec, usec)
80
+ rescue
81
+ offset = utc_or_local == :local ? (::Time.local(2007).utc_offset.to_r/86400) : 0
82
+ ::DateTime.civil(year, month, day, hour, min, sec, offset)
83
+ end
84
+
85
+ # Enforce strict date part validity which the Time class does not.
86
+ # Only does full date check if month and day are possibly invalid.
87
+ def fast_date_valid_with_fallback(year, month, day)
88
+ month < 13 && (day < 29 || Date.valid_civil?(year, month, day))
89
+ end
90
+
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,3 @@
1
+ module Timeliness
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,36 @@
1
+ require 'rspec'
2
+
3
+ require 'active_support/time'
4
+ require 'timecop'
5
+ require 'timeliness'
6
+
7
+ module TimelinessHelpers
8
+ def parser
9
+ Timeliness::Parser
10
+ end
11
+
12
+ def formats
13
+ Timeliness::Formats
14
+ end
15
+
16
+ def parse(*args)
17
+ Timeliness::Parser.parse(*args)
18
+ end
19
+
20
+ def current_date(options={})
21
+ Timeliness::Parser.send(:current_date, options)
22
+ end
23
+
24
+ def should_parse(*args)
25
+ Timeliness::Parser.parse(*args).should_not be_nil
26
+ end
27
+
28
+ def should_not_parse(*args)
29
+ Timeliness::Parser.parse(*args).should be_nil
30
+ end
31
+ end
32
+
33
+ Rspec.configure do |c|
34
+ c.mock_with :rspec
35
+ c.include TimelinessHelpers
36
+ end
@@ -0,0 +1,106 @@
1
+ require 'spec_helper'
2
+
3
+ describe Timeliness::FormatSet do
4
+ context ".define_format_method" do
5
+ it "should define method which outputs date array with values in correct order" do
6
+ define_method_for('yyyy-mm-dd').call('2000', '1', '2').should == [2000,1,2,nil,nil,nil,nil]
7
+ end
8
+
9
+ it "should define method which outputs date array from format with different order" do
10
+ define_method_for('dd/mm/yyyy').call('2', '1', '2000').should == [2000,1,2,nil,nil,nil,nil]
11
+ end
12
+
13
+ it "should define method which outputs time array" do
14
+ define_method_for('hh:nn:ss').call('01', '02', '03').should == [nil,nil,nil,1,2,3,nil]
15
+ end
16
+
17
+ it "should define method which outputs time array with meridian 'pm' adjusted hour" do
18
+ define_method_for('hh:nn:ss ampm').call('01', '02', '03', 'pm').should == [nil,nil,nil,13,2,3,nil]
19
+ end
20
+
21
+ it "should define method which outputs time array with meridian 'am' unadjusted hour" do
22
+ define_method_for('hh:nn:ss ampm').call('01', '02', '03', 'am').should == [nil,nil,nil,1,2,3,nil]
23
+ end
24
+
25
+ it "should define method which outputs time array with microseconds" do
26
+ define_method_for('hh:nn:ss.u').call('01', '02', '03', '99').should == [nil,nil,nil,1,2,3,990000]
27
+ end
28
+
29
+ it "should define method which outputs datetime array with zone offset" do
30
+ define_method_for('yyyy-mm-dd hh:nn:ss.u zo').call('2001', '02', '03', '04', '05', '06', '99', '+10:00').should == [2001,2,3,4,5,6,990000,36000]
31
+ end
32
+ end
33
+
34
+ context "compiled regexp" do
35
+
36
+ context "for time formats" do
37
+ format_tests = {
38
+ 'hh:nn:ss' => {:pass => ['12:12:12', '01:01:01'], :fail => ['1:12:12', '12:1:12', '12:12:1', '12-12-12']},
39
+ 'hh-nn-ss' => {:pass => ['12-12-12', '01-01-01'], :fail => ['1-12-12', '12-1-12', '12-12-1', '12:12:12']},
40
+ 'h:nn' => {:pass => ['12:12', '1:01'], :fail => ['12:2', '12-12']},
41
+ 'h.nn' => {:pass => ['2.12', '12.12'], :fail => ['2.1', '12:12']},
42
+ 'h nn' => {:pass => ['2 12', '12 12'], :fail => ['2 1', '2.12', '12:12']},
43
+ 'h-nn' => {:pass => ['2-12', '12-12'], :fail => ['2-1', '2.12', '12:12']},
44
+ 'h:nn_ampm' => {:pass => ['2:12am', '2:12 pm', '2:12 AM', '2:12PM'], :fail => ['1:2am', '1:12 pm', '2.12am']},
45
+ 'h.nn_ampm' => {:pass => ['2.12am', '2.12 pm'], :fail => ['1:2am', '1:12 pm', '2:12am']},
46
+ 'h nn_ampm' => {:pass => ['2 12am', '2 12 pm'], :fail => ['1 2am', '1 12 pm', '2:12am']},
47
+ 'h-nn_ampm' => {:pass => ['2-12am', '2-12 pm'], :fail => ['1-2am', '1-12 pm', '2:12am']},
48
+ 'h_ampm' => {:pass => ['2am', '2 am', '12 pm'], :fail => ['1.am', '12 pm', '2:12am']},
49
+ }
50
+ format_tests.each do |format, values|
51
+ it "should correctly match times in format '#{format}'" do
52
+ regexp = compile_regexp(format)
53
+ values[:pass].each {|value| value.should match(regexp)}
54
+ values[:fail].each {|value| value.should_not match(regexp)}
55
+ end
56
+ end
57
+ end
58
+
59
+ context "for date formats" do
60
+ format_tests = {
61
+ 'yyyy/mm/dd' => {:pass => ['2000/02/01'], :fail => ['2000\02\01', '2000/2/1', '00/02/01']},
62
+ 'yyyy-mm-dd' => {:pass => ['2000-02-01'], :fail => ['2000\02\01', '2000-2-1', '00-02-01']},
63
+ 'yyyy.mm.dd' => {:pass => ['2000.02.01'], :fail => ['2000\02\01', '2000.2.1', '00.02.01']},
64
+ 'm/d/yy' => {:pass => ['2/1/01', '02/01/00', '02/01/2000'], :fail => ['2/1/0', '2.1.01']},
65
+ 'd/m/yy' => {:pass => ['1/2/01', '01/02/00', '01/02/2000'], :fail => ['1/2/0', '1.2.01']},
66
+ 'm\d\yy' => {:pass => ['2\1\01', '2\01\00', '02\01\2000'], :fail => ['2\1\0', '2/1/01']},
67
+ 'd\m\yy' => {:pass => ['1\2\01', '1\02\00', '01\02\2000'], :fail => ['1\2\0', '1/2/01']},
68
+ 'd-m-yy' => {:pass => ['1-2-01', '1-02-00', '01-02-2000'], :fail => ['1-2-0', '1/2/01']},
69
+ 'd.m.yy' => {:pass => ['1.2.01', '1.02.00', '01.02.2000'], :fail => ['1.2.0', '1/2/01']},
70
+ 'd mmm yy' => {:pass => ['1 Feb 00', '1 Feb 2000', '1 February 00', '01 February 2000'],
71
+ :fail => ['1 Fe 00', 'Feb 1 2000', '1 Feb 0']}
72
+ }
73
+ format_tests.each do |format, values|
74
+ it "should correctly match dates in format '#{format}'" do
75
+ regexp = compile_regexp(format)
76
+ values[:pass].each {|value| value.should match(regexp)}
77
+ values[:fail].each {|value| value.should_not match(regexp)}
78
+ end
79
+ end
80
+ end
81
+
82
+ context "for datetime formats" do
83
+ format_tests = {
84
+ 'ddd mmm d hh:nn:ss zo yyyy' => {:pass => ['Sat Jul 19 12:00:00 +1000 2008'], :fail => []},
85
+ 'yyyy-mm-ddThh:nn:ss(?:Z|zo)' => {:pass => ['2008-07-19T12:00:00+10:00', '2008-07-19T12:00:00Z'], :fail => ['2008-07-19T12:00:00Z+10:00']},
86
+ }
87
+ format_tests.each do |format, values|
88
+ it "should correctly match datetimes in format '#{format}'" do
89
+ regexp = compile_regexp(format)
90
+ values[:pass].each {|value| value.should match(regexp)}
91
+ values[:fail].each {|value| value.should_not match(regexp)}
92
+ end
93
+ end
94
+ end
95
+
96
+ end
97
+
98
+ def define_method_for(format)
99
+ Timeliness::FormatSet.compile([format]).method(:"format_#{format}")
100
+ end
101
+
102
+ def compile_regexp(format)
103
+ Timeliness::FormatSet.compile([format]).regexp
104
+ end
105
+
106
+ end