tickle 1.0.2 → 2.0.0rc3
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.
- checksums.yaml +5 -5
- data/.gitignore +3 -1
- data/.travis.yml +12 -0
- data/CHANGES.md +25 -3
- data/Gemfile +14 -1
- data/LICENCE +1 -1
- data/README.md +1 -1
- data/Rakefile +3 -8
- data/benchmarks/main.rb +71 -0
- data/lib/ext/array.rb +6 -0
- data/lib/ext/date_and_time.rb +64 -0
- data/lib/ext/string.rb +39 -0
- data/lib/tickle.rb +23 -113
- data/lib/tickle/filters.rb +58 -0
- data/lib/tickle/handler.rb +223 -80
- data/lib/tickle/helpers.rb +51 -0
- data/lib/tickle/patterns.rb +115 -0
- data/lib/tickle/repeater.rb +232 -116
- data/lib/tickle/tickle.rb +97 -201
- data/lib/tickle/tickled.rb +173 -0
- data/lib/tickle/token.rb +94 -0
- data/lib/tickle/version.rb +2 -2
- data/spec/helpers_spec.rb +36 -0
- data/spec/patterns_spec.rb +240 -0
- data/spec/spec_helper.rb +43 -0
- data/spec/tickle_spec.rb +551 -0
- data/spec/token_spec.rb +82 -0
- data/tickle.gemspec +4 -9
- metadata +34 -74
- data/lib/numerizer/numerizer.rb +0 -103
@@ -0,0 +1,58 @@
|
|
1
|
+
module Tickle
|
2
|
+
|
3
|
+
require 'texttube/filterable'
|
4
|
+
module Filters
|
5
|
+
|
6
|
+
extend TextTube::Filterable
|
7
|
+
# Normalize natural string removing prefix language
|
8
|
+
filter_with :remove_prefix do |text|
|
9
|
+
text.gsub(/every(\s)?/, '')
|
10
|
+
.gsub(/each(\s)?/, '')
|
11
|
+
.gsub(/repeat(s|ing)?(\s)?/, '')
|
12
|
+
.gsub(/on the(\s)?/, '')
|
13
|
+
.gsub(/([^\w\d\s])+/, '')
|
14
|
+
.downcase.strip
|
15
|
+
text
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
# Converts natural language US Holidays into a date expression to be
|
20
|
+
# parsed.
|
21
|
+
filter_with :normalize_us_holidays do |text|
|
22
|
+
normalized_text = text.to_s.downcase
|
23
|
+
normalized_text.gsub(/\bnew\syear'?s?(\s)?(day)?\b/){|md| $1 }
|
24
|
+
.gsub(/\bnew\syear'?s?(\s)?(eve)?\b/){|md| $1 }
|
25
|
+
.gsub(/\bm(artin\s)?l(uther\s)?k(ing)?(\sday)?\b/){|md| $1 }
|
26
|
+
.gsub(/\binauguration(\sday)?\b/){|md| $1 }
|
27
|
+
.gsub(/\bpresident'?s?(\sday)?\b/){|md| $1 }
|
28
|
+
.gsub(/\bmemorial\sday\b/){|md| $1 }
|
29
|
+
.gsub(/\bindepend(e|a)nce\sday\b/){|md| $1 }
|
30
|
+
.gsub(/\blabor\sday\b/){|md| $1 }
|
31
|
+
.gsub(/\bcolumbus\sday\b/){|md| $1 }
|
32
|
+
.gsub(/\bveterans?\sday\b/){|md| $1 }
|
33
|
+
.gsub(/\bthanksgiving(\sday)?\b/){|md| $1 }
|
34
|
+
.gsub(/\bchristmas\seve\b/){|md| $1 }
|
35
|
+
.gsub(/\bchristmas(\sday)?\b/){|md| $1 }
|
36
|
+
.gsub(/\bsuper\sbowl(\ssunday)?\b/){|md| $1 }
|
37
|
+
.gsub(/\bgroundhog(\sday)?\b/){|md| $1 }
|
38
|
+
.gsub(/\bvalentine'?s?(\sday)?\b/){|md| $1 }
|
39
|
+
.gsub(/\bs(ain)?t\spatrick'?s?(\sday)?\b/){|md| $1 }
|
40
|
+
.gsub(/\bapril\sfool'?s?(\sday)?\b/){|md| $1 }
|
41
|
+
.gsub(/\bearth\sday\b/){|md| $1 }
|
42
|
+
.gsub(/\barbor\sday\b/){|md| $1 }
|
43
|
+
.gsub(/\bcinco\sde\smayo\b/){|md| $1 }
|
44
|
+
.gsub(/\bmother'?s?\sday\b/){|md| $1 }
|
45
|
+
.gsub(/\bflag\sday\b/){|md| $1 }
|
46
|
+
.gsub(/\bfather'?s?\sday\b/){|md| $1 }
|
47
|
+
.gsub(/\bhalloween\b/){|md| $1 }
|
48
|
+
.gsub(/\belection\sday\b/){|md| $1 }
|
49
|
+
.gsub(/\bkwanzaa\b/){|md| $1 }
|
50
|
+
normalized_text
|
51
|
+
end
|
52
|
+
|
53
|
+
# filter_with :strip do |text|
|
54
|
+
# text.strip
|
55
|
+
# end
|
56
|
+
|
57
|
+
end
|
58
|
+
end
|
data/lib/tickle/handler.rb
CHANGED
@@ -1,129 +1,272 @@
|
|
1
|
-
module Tickle
|
2
|
-
|
1
|
+
module Tickle
|
2
|
+
|
3
|
+
require_relative "helpers.rb"
|
4
|
+
require_relative "token.rb"
|
5
|
+
|
3
6
|
|
4
7
|
# The heavy lifting. Goes through each token groupings to determine what natural language should either by
|
5
8
|
# parsed by Chronic or returned. This methodology makes extension fairly simple, as new token types can be
|
6
9
|
# easily added in repeater and then processed by the guess method
|
7
10
|
#
|
8
|
-
def guess()
|
9
|
-
return nil if
|
11
|
+
def self.guess(tokens, start)
|
12
|
+
return nil if tokens.empty?
|
10
13
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
guess_special unless @next
|
14
|
+
_next = catch(:guessed) {
|
15
|
+
%w{guess_unit_types guess_weekday guess_month_names guess_number_and_unit guess_ordinal guess_ordinal_and_unit guess_special}.each do |meth| # TODO pick better enumerator
|
16
|
+
send meth, tokens, start
|
17
|
+
end
|
18
|
+
nil # stop each sending the array to _next
|
19
|
+
}
|
18
20
|
|
19
21
|
# check to see if next is less than now and, if so, set it to next year
|
20
|
-
|
21
|
-
|
22
|
+
if _next &&
|
23
|
+
_next.to_date < start.to_date
|
24
|
+
_next = Time.local(_next.year + 1, _next.month, _next.day, _next.hour, _next.min, _next.sec)
|
25
|
+
end
|
22
26
|
# return the next occurrence
|
23
|
-
|
27
|
+
_next.to_time if _next
|
24
28
|
end
|
25
29
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
30
|
+
|
31
|
+
def self.guess_unit_types( tokens, start)
|
32
|
+
[:sec,:day,:week,:month,:year].each {|unit|
|
33
|
+
if Token.types(tokens).same?([unit])
|
34
|
+
throw :guessed, start.bump(unit)
|
35
|
+
end
|
36
|
+
}
|
37
|
+
nil
|
31
38
|
end
|
32
39
|
|
33
|
-
|
34
|
-
|
40
|
+
|
41
|
+
def self.guess_weekday( tokens, start)
|
42
|
+
if Token.types(tokens).same? [:weekday]
|
43
|
+
throw :guessed, chronic_parse_with_start(
|
44
|
+
"#{Token.token_of_type(:weekday,tokens).start.to_s}", start
|
45
|
+
)
|
46
|
+
end
|
47
|
+
nil
|
35
48
|
end
|
36
49
|
|
37
|
-
|
38
|
-
|
50
|
+
|
51
|
+
def self.guess_month_names( tokens, start)
|
52
|
+
if Token.types(tokens).same? [:month_name]
|
53
|
+
throw :guessed, chronic_parse_with_start(
|
54
|
+
"#{Date::MONTHNAMES[Token.token_of_type(:month_name,tokens).start]} 1", start
|
55
|
+
)
|
56
|
+
end
|
57
|
+
nil
|
39
58
|
end
|
40
59
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
60
|
+
|
61
|
+
def self.guess_number_and_unit( tokens, start)
|
62
|
+
_next =
|
63
|
+
[:sec,:day,:week,:month,:year].each {|unit|
|
64
|
+
if Token.types(tokens).same?([:number, unit])
|
65
|
+
throw :guessed, start.bump( unit, Token.token_of_type(:number,tokens).interval )
|
66
|
+
end
|
67
|
+
}
|
68
|
+
|
69
|
+
if Token.types(tokens).same?([:number, :month_name])
|
70
|
+
throw :guessed, chronic_parse_with_start(
|
71
|
+
"#{Token.token_of_type(:month_name,tokens, start).word} #{Token.token_of_type(:number,tokens).start}", start
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
if Token.types(tokens).same?([:number, :month_name, :specific_year])
|
76
|
+
throw :guessed, chronic_parse_with_start(
|
77
|
+
[
|
78
|
+
Token.token_of_type(:specific_year,tokens, start).word,
|
79
|
+
Token.token_of_type(:month_name,tokens).start,
|
80
|
+
Token.token_of_type(:number,tokens).start
|
81
|
+
].join("_"), start
|
82
|
+
)
|
83
|
+
end
|
84
|
+
nil
|
48
85
|
end
|
49
86
|
|
50
|
-
|
51
|
-
|
87
|
+
|
88
|
+
def self.guess_ordinal( tokens, start)
|
89
|
+
if Token.types(tokens).same?([:ordinal])
|
90
|
+
throw :guessed, handle_same_day_chronic_issue(
|
91
|
+
start.year, start.month, Token.token_of_type(:ordinal,tokens).start, start
|
92
|
+
)
|
93
|
+
end
|
94
|
+
nil
|
52
95
|
end
|
53
96
|
|
54
|
-
def guess_ordinal_and_unit
|
55
|
-
@next = handle_same_day_chronic_issue(@start.year, token_of_type(:month_name).start, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month_name])
|
56
|
-
@next = handle_same_day_chronic_issue(@start.year, @start.month, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month])
|
57
|
-
@next = handle_same_day_chronic_issue(token_of_type(:specific_year).word, token_of_type(:month_name).start, token_of_type(:ordinal).start) if token_types.same?([:ordinal, :month_name, :specific_year])
|
58
97
|
|
59
|
-
|
60
|
-
|
61
|
-
|
98
|
+
def self.guess_ordinal_and_unit( tokens, start)
|
99
|
+
if Token.types(tokens).same?([:ordinal, :month_name])
|
100
|
+
throw :guessed, handle_same_day_chronic_issue(
|
101
|
+
start.year, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start
|
102
|
+
)
|
103
|
+
nil
|
104
|
+
end
|
105
|
+
|
106
|
+
if Token.types(tokens).same?([:ordinal, :month])
|
107
|
+
throw :guessed, handle_same_day_chronic_issue(
|
108
|
+
start.year,
|
109
|
+
start.month,
|
110
|
+
Token.token_of_type(:ordinal,tokens).start,
|
111
|
+
start
|
112
|
+
)
|
113
|
+
nil
|
62
114
|
end
|
63
115
|
|
64
|
-
if
|
65
|
-
|
66
|
-
|
116
|
+
if Token.types(tokens).same?([:ordinal, :month_name, :specific_year])
|
117
|
+
throw :guessed, handle_same_day_chronic_issue(
|
118
|
+
Token.token_of_type(:specific_year,tokens).word, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start
|
119
|
+
)
|
120
|
+
nil
|
67
121
|
end
|
122
|
+
|
123
|
+
if Token.types(tokens).same?([:ordinal, :weekday, :month_name])
|
124
|
+
_next = chronic_parse_with_start(
|
125
|
+
"#{Token.token_of_type(:ordinal,tokens).word} #{Token.token_of_type(:weekday,tokens).start.to_s} in #{Date::MONTHNAMES[Token.token_of_type(:month_name,tokens).start]}", start
|
126
|
+
)
|
127
|
+
if _next.to_date == start.to_date
|
128
|
+
throw :guessed, handle_same_day_chronic_issue(start.year, Token.token_of_type(:month_name,tokens).start, Token.token_of_type(:ordinal,tokens).start, start)
|
129
|
+
end
|
130
|
+
throw :guessed, _next
|
131
|
+
nil
|
132
|
+
end
|
133
|
+
|
134
|
+
if Token.types(tokens).same?([:ordinal, :weekday, :month])
|
135
|
+
_next = chronic_parse_with_start(
|
136
|
+
"#{Token.token_of_type(:ordinal,tokens).word} #{Token.token_of_type(:weekday,tokens).start.to_s} in #{Date::MONTHNAMES[get_next_month(Token.token_of_type(:ordinal,tokens).start)]}", start
|
137
|
+
)
|
138
|
+
_next =
|
139
|
+
if _next.to_date == start.to_date
|
140
|
+
handle_same_day_chronic_issue(
|
141
|
+
start.year, start.month, Token.token_of_type(:ordinal,tokens).start, start
|
142
|
+
)
|
143
|
+
else
|
144
|
+
_next
|
145
|
+
end
|
146
|
+
throw :guessed, _next
|
147
|
+
nil
|
148
|
+
end
|
149
|
+
nil
|
68
150
|
end
|
69
151
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
152
|
+
|
153
|
+
def self.guess_special( tokens, start)
|
154
|
+
guess_special_other tokens, start
|
155
|
+
guess_special_beginning tokens, start
|
156
|
+
guess_special_middle tokens, start
|
157
|
+
guess_special_end tokens, start
|
158
|
+
nil
|
75
159
|
end
|
76
160
|
|
77
161
|
private
|
78
162
|
|
79
|
-
def guess_special_other
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
163
|
+
def self.guess_special_other( tokens, start)
|
164
|
+
if Token.types(tokens).same?([:special, :day]) &&
|
165
|
+
Token.token_of_type(:special, tokens).start == :other
|
166
|
+
throw :guessed, start.bump(:day, 2)
|
167
|
+
nil
|
168
|
+
end
|
169
|
+
|
170
|
+
if Token.types(tokens).same?([:special, :week]) &&
|
171
|
+
Token.token_of_type(:special, tokens).start == :other
|
172
|
+
throw :guessed, start.bump(:week, 2)
|
173
|
+
nil
|
174
|
+
end
|
175
|
+
|
176
|
+
if Token.types(tokens).same?([:special, :month]) &&
|
177
|
+
Token.token_of_type(:special, tokens).start == :other
|
178
|
+
throw :guessed, chronic_parse_with_start('2 months from now', start)
|
179
|
+
nil
|
180
|
+
end
|
85
181
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
182
|
+
if Token.types(tokens).same?([:special, :year]) &&
|
183
|
+
Token.token_of_type(:special, tokens).start == :other
|
184
|
+
throw :guessed, chronic_parse_with_start('2 years from now', start)
|
185
|
+
nil
|
186
|
+
end
|
187
|
+
nil
|
90
188
|
end
|
91
189
|
|
92
|
-
|
93
|
-
|
94
|
-
if
|
95
|
-
|
190
|
+
|
191
|
+
def self.guess_special_beginning( tokens, start)
|
192
|
+
if Token.types(tokens).same?([:special, :week]) &&
|
193
|
+
Token.token_of_type(:special, tokens).start == :beginning
|
194
|
+
throw :guessed, chronic_parse_with_start('Sunday', start)
|
195
|
+
nil
|
196
|
+
end
|
197
|
+
if Token.types(tokens).same?([:special, :month]) &&
|
198
|
+
Token.token_of_type(:special, tokens).start == :beginning
|
199
|
+
throw :guessed, Date.civil(start.year, start.month + 1, 1)
|
200
|
+
nil
|
201
|
+
end
|
202
|
+
if Token.types(tokens).same?([:special, :year]) &&
|
203
|
+
Token.token_of_type(:special, tokens).start == :beginning
|
204
|
+
throw :guessed, Date.civil(start.year+1, 1, 1)
|
205
|
+
nil
|
206
|
+
end
|
207
|
+
nil
|
96
208
|
end
|
97
209
|
|
98
|
-
def guess_special_middle
|
99
|
-
if
|
100
|
-
|
101
|
-
|
210
|
+
def self.guess_special_middle( tokens, start)
|
211
|
+
if Token.types(tokens).same?([:special, :week]) &&
|
212
|
+
Token.token_of_type(:special, tokens).start == :middle
|
213
|
+
throw :guessed, chronic_parse_with_start('Wednesday', start)
|
214
|
+
nil
|
102
215
|
end
|
103
|
-
|
104
|
-
|
216
|
+
|
217
|
+
if Token.types(tokens).same?([:special, :month]) &&
|
218
|
+
Token.token_of_type(:special, tokens).start == :middle
|
219
|
+
_next = start.day > 15 ?
|
220
|
+
Date.civil(start.year, start.month + 1, 15) :
|
221
|
+
Date.civil(start.year, start.month, 15)
|
222
|
+
throw :guessed, _next
|
223
|
+
nil
|
224
|
+
end
|
225
|
+
|
226
|
+
if Token.types(tokens).same?([:special, :year]) &&
|
227
|
+
Token.token_of_type(:special, tokens).start == :middle
|
228
|
+
_next =
|
229
|
+
start.day > 15 && start.month > 6 ?
|
230
|
+
Date.new(start.year+1, 6, 15) :
|
231
|
+
Date.new(start.year, 6, 15)
|
232
|
+
throw :guessed, _next
|
233
|
+
nil
|
105
234
|
end
|
235
|
+
nil
|
106
236
|
end
|
107
237
|
|
108
|
-
|
109
|
-
|
238
|
+
|
239
|
+
def self.guess_special_end( tokens, start)
|
240
|
+
if Token.types(tokens).same?([:special, :week]) &&
|
241
|
+
(Token.token_of_type(:special, tokens).start == :end)
|
242
|
+
throw :guessed, chronic_parse_with_start('Saturday', start)
|
243
|
+
nil
|
244
|
+
end
|
245
|
+
if Token.types(tokens).same?([:special, :month]) &&
|
246
|
+
(Token.token_of_type(:special, tokens).start == :end)
|
247
|
+
throw :guessed, Date.civil(start.year, start.month, -1)
|
248
|
+
nil
|
249
|
+
end
|
250
|
+
if Token.types(tokens).same?([:special, :year]) &&
|
251
|
+
(Token.token_of_type(:special, tokens).start == :end)
|
252
|
+
throw :guessed, Date.new(start.year, 12, 31)
|
253
|
+
nil
|
254
|
+
end
|
255
|
+
nil
|
110
256
|
end
|
111
257
|
|
112
|
-
private
|
113
258
|
|
114
259
|
# runs Chronic.parse with now being set to the specified start date for Tickle parsing
|
115
|
-
def chronic_parse_with_start(exp)
|
116
|
-
|
117
|
-
Chronic.parse(exp, :now => @start)
|
260
|
+
def self.chronic_parse_with_start(exp,start)
|
261
|
+
Chronic.parse(exp, :now => start)
|
118
262
|
end
|
119
263
|
|
120
264
|
# needed to handle the unique situation where a number or ordinal plus optional month or month name is passed that is EQUAL to the start date since Chronic returns that day.
|
121
|
-
def handle_same_day_chronic_issue(year, month, day)
|
122
|
-
|
123
|
-
|
124
|
-
|
265
|
+
def self.handle_same_day_chronic_issue(year, month, day, start)
|
266
|
+
arg_date =
|
267
|
+
Date.new(year.to_i, month.to_i, day.to_i) == start.to_date ?
|
268
|
+
Time.local(year, month+1, day) :
|
269
|
+
Time.local(year, month, day)
|
270
|
+
arg_date
|
125
271
|
end
|
126
|
-
|
127
|
-
|
128
|
-
end
|
129
272
|
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Tickle
|
2
|
+
|
3
|
+
require_relative "token.rb"
|
4
|
+
|
5
|
+
# static methods that are used across classes.
|
6
|
+
module Helpers
|
7
|
+
|
8
|
+
# Returns the next available month based on the current day of the month.
|
9
|
+
# For example, if get_next_month(15) is called and the start date is the 10th, then it will return the 15th of this month.
|
10
|
+
# However, if get_next_month(15) is called and the start date is the 18th, it will return the 15th of next month.
|
11
|
+
def self.get_next_month(number,start=nil)
|
12
|
+
start ||= @start || Time.now
|
13
|
+
month =
|
14
|
+
if number.to_i < start.day
|
15
|
+
start.month == 12 ?
|
16
|
+
1 :
|
17
|
+
start.month + 1
|
18
|
+
else
|
19
|
+
start.month
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
# Return the number of days in a specified month.
|
26
|
+
# If no month is specified, current month is used.
|
27
|
+
def self.days_in_month(month=nil)
|
28
|
+
month ||= Date.today.month
|
29
|
+
days_in_mon = Date.civil(Date.today.year, month, -1).day
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
# Turns compound numbers, like 'twenty first' => 21
|
34
|
+
def self.combine_multiple_numbers(tokens)
|
35
|
+
if Token.types(tokens).include?(:number) &&
|
36
|
+
Token.types(tokens).include?(:ordinal)
|
37
|
+
number = Token.token_of_type(:number, tokens)
|
38
|
+
ordinal = Token.token_of_type(:ordinal, tokens)
|
39
|
+
combined_original = "#{number.original} #{ordinal.original}"
|
40
|
+
combined_word = (number.start.to_s[0] + ordinal.word)
|
41
|
+
combined_value = (number.start.to_s[0] + ordinal.start.to_s)
|
42
|
+
new_number_token = Token.new(combined_original, word: combined_word, type: :ordinal, start: combined_value, interval: 365)
|
43
|
+
tokens.reject! {|token| (token.type == :number || token.type == :ordinal)}
|
44
|
+
tokens << new_number_token
|
45
|
+
end
|
46
|
+
tokens
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
end # Helpers
|
51
|
+
end
|