acute 1.0.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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/data/assumptions.yml +124 -0
- data/data/words.yml +699 -0
- data/exe/acute +4 -0
- data/lib/acute/assumption.rb +13 -0
- data/lib/acute/calendar.rb +91 -0
- data/lib/acute/chronic.rb +176 -0
- data/lib/acute/chronic_duration.rb +321 -0
- data/lib/acute/cli.rb +168 -0
- data/lib/acute/context.rb +205 -0
- data/lib/acute/describe.rb +316 -0
- data/lib/acute/duration.rb +120 -0
- data/lib/acute/errors.rb +47 -0
- data/lib/acute/explain.rb +165 -0
- data/lib/acute/extract.rb +493 -0
- data/lib/acute/grammar/clock_words.rb +109 -0
- data/lib/acute/grammar/compact.rb +222 -0
- data/lib/acute/grammar/duration.rb +93 -0
- data/lib/acute/grammar/nesting.rb +104 -0
- data/lib/acute/grammar/numbers.rb +378 -0
- data/lib/acute/grammar/ordinals.rb +101 -0
- data/lib/acute/grammar/ranges.rb +112 -0
- data/lib/acute/grammar/rule.rb +277 -0
- data/lib/acute/grammar/shifts.rb +226 -0
- data/lib/acute/grammar/words.rb +307 -0
- data/lib/acute/grammar.rb +378 -0
- data/lib/acute/holidays.rb +97 -0
- data/lib/acute/lexer.rb +487 -0
- data/lib/acute/locale.rb +53 -0
- data/lib/acute/mcp.rb +224 -0
- data/lib/acute/parser.rb +66 -0
- data/lib/acute/read.rb +21 -0
- data/lib/acute/resolve/clock.rb +75 -0
- data/lib/acute/resolve/dates.rb +75 -0
- data/lib/acute/resolve/day.rb +24 -0
- data/lib/acute/resolve/fuzzy.rb +91 -0
- data/lib/acute/resolve/named.rb +116 -0
- data/lib/acute/resolve/nth.rb +47 -0
- data/lib/acute/resolve/period.rb +160 -0
- data/lib/acute/resolve/periodic.rb +274 -0
- data/lib/acute/resolve/ranges.rb +165 -0
- data/lib/acute/resolve/recur.rb +187 -0
- data/lib/acute/resolve/rel.rb +146 -0
- data/lib/acute/resolve/shift.rb +81 -0
- data/lib/acute/resolve.rb +245 -0
- data/lib/acute/result.rb +128 -0
- data/lib/acute/rule/cron.rb +222 -0
- data/lib/acute/rule/expand.rb +401 -0
- data/lib/acute/rule.rb +232 -0
- data/lib/acute/tables/assumptions.rb +39 -0
- data/lib/acute/tables/words.rb +656 -0
- data/lib/acute/tables/zones.rb +930 -0
- data/lib/acute/tool/compact.rb +82 -0
- data/lib/acute/tool/ruby_llm.rb +29 -0
- data/lib/acute/tool.rb +192 -0
- data/lib/acute/verify.rb +216 -0
- data/lib/acute/version.rb +3 -0
- data/lib/acute/zone.rb +252 -0
- data/lib/acute.rb +160 -0
- data/schema/description.json +12 -0
- data/schema/duration.json +26 -0
- data/schema/extraction.json +30 -0
- data/schema/failure.json +29 -0
- data/schema/now.json +17 -0
- data/schema/result.json +45 -0
- data/schema/rule.json +19 -0
- data/schema/tools/describe.json +13 -0
- data/schema/tools/duration.json +12 -0
- data/schema/tools/extract.json +13 -0
- data/schema/tools/now.json +9 -0
- data/schema/tools/parse.json +13 -0
- data/schema/tools/recurrence.json +12 -0
- data/schema/tools/verify.json +14 -0
- data/schema/verdict.json +25 -0
- metadata +117 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Proleptic Gregorian dates as day numbers (days since 1970-01-01), after Howard Hinnant's algorithms.
|
|
2
|
+
module Acute
|
|
3
|
+
# @api private
|
|
4
|
+
module Calendar
|
|
5
|
+
MONTH_DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
6
|
+
|
|
7
|
+
def self.days_from_civil(y, m, d)
|
|
8
|
+
y -= 1 if m <= 2
|
|
9
|
+
era = y / 400
|
|
10
|
+
yoe = y - era * 400
|
|
11
|
+
doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 + d - 1
|
|
12
|
+
doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
|
|
13
|
+
era * 146_097 + doe - 719_468
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The day number of January 1st of year y
|
|
17
|
+
def self.year_start(y)
|
|
18
|
+
y -= 1
|
|
19
|
+
365 * y + y / 4 - y / 100 + y / 400 - 719_162
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The year whose January 1st is day d, or nil when d is no January 1st. The guess counts average years
|
|
23
|
+
# to the year's middle, which is exact for every January 1st.
|
|
24
|
+
def self.year_if_first(d)
|
|
25
|
+
y = (d + 719_344) * 400 / 146_097
|
|
26
|
+
y + 1 if 365 * y + y / 4 - y / 100 + y / 400 - 719_162 == d
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The year holding day d. The guess counts average years from year 1 and is the year or the one before.
|
|
30
|
+
def self.year_of(d)
|
|
31
|
+
y = (d + 719_162) * 400 / 146_097 + 1
|
|
32
|
+
365 * y + y / 4 - y / 100 + y / 400 - 719_162 <= d ? y + 1 : y
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# D17: the day n (not 0) Mondays to Fridays after day d, or before it when n is negative. A weekend counts from the
|
|
36
|
+
# Friday before it going forward, and the Monday after it going back.
|
|
37
|
+
def self.add_weekdays(d, n)
|
|
38
|
+
m = (d + 3) % 7 # Monday 0 to Sunday 6
|
|
39
|
+
if n > 0
|
|
40
|
+
d -= m - 4 if m > 4
|
|
41
|
+
r = n % 5
|
|
42
|
+
d + n / 5 * 7 + r + ((m > 4 ? 4 : m) + r > 4 ? 2 : 0)
|
|
43
|
+
else
|
|
44
|
+
n = -n
|
|
45
|
+
d += 7 - m if m > 4
|
|
46
|
+
r = n % 5
|
|
47
|
+
d - n / 5 * 7 - r - ((m > 4 ? 0 : m) < r ? 2 : 0)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Packed as y * 10000 + m * 100 + d, so the hot path allocates no Array
|
|
52
|
+
def self.civil_from_days(z)
|
|
53
|
+
z += 719_468
|
|
54
|
+
era = z / 146_097
|
|
55
|
+
doe = z - era * 146_097
|
|
56
|
+
yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365
|
|
57
|
+
y = yoe + era * 400
|
|
58
|
+
doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
|
|
59
|
+
mp = (5 * doy + 2) / 153
|
|
60
|
+
d = doy - (153 * mp + 2) / 5 + 1
|
|
61
|
+
m = mp < 10 ? mp + 3 : mp - 9
|
|
62
|
+
y += 1 if m <= 2
|
|
63
|
+
y * 10_000 + m * 100 + d
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.leap?(y) = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
|
67
|
+
|
|
68
|
+
def self.days_in_month(y, m) = m == 2 && leap?(y) ? 29 : MONTH_DAYS[m]
|
|
69
|
+
|
|
70
|
+
# 0 = Sunday
|
|
71
|
+
def self.weekday(days) = (days + 4) % 7
|
|
72
|
+
|
|
73
|
+
# The Monday that starts ISO week 1 of year y: the week holding January 4th
|
|
74
|
+
def self.iso_week_one(y)
|
|
75
|
+
jan4 = days_from_civil(y, 1, 4)
|
|
76
|
+
jan4 - (jan4 + 3) % 7
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The day number of Western Easter Sunday, by the anonymous Gregorian algorithm (Meeus, Jones, Butcher)
|
|
80
|
+
def self.easter(y)
|
|
81
|
+
a = y % 19
|
|
82
|
+
b = y / 100
|
|
83
|
+
c = y % 100
|
|
84
|
+
h = (19 * a + b - b / 4 - (b - (b + 8) / 25 + 1) / 3 + 15) % 30
|
|
85
|
+
l = (32 + 2 * (b % 4) + 2 * (c / 4) - h - c % 4) % 7
|
|
86
|
+
m = (a + 11 * h + 22 * l) / 451
|
|
87
|
+
n = h + l - 7 * m + 114
|
|
88
|
+
days_from_civil(y, n / 31, n % 31 + 1)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require_relative "../acute"
|
|
2
|
+
|
|
3
|
+
# chronic 0.10.2's API on acute's parser, for code written against chronic. It takes chronic's options and
|
|
4
|
+
# returns what chronic returns, but the answers follow acute's rules (D46).
|
|
5
|
+
module Chronic
|
|
6
|
+
VERSION = "0.10.2".freeze
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :time_class, :debug
|
|
10
|
+
|
|
11
|
+
def parse(text, options = {}) = Parser.new(options).parse(text)
|
|
12
|
+
end
|
|
13
|
+
self.time_class = ::Time
|
|
14
|
+
self.debug = false
|
|
15
|
+
|
|
16
|
+
class Span < Range
|
|
17
|
+
def width = (self.end - self.begin).to_i
|
|
18
|
+
|
|
19
|
+
def +(other) = Span.new(self.begin + other, self.end + other)
|
|
20
|
+
|
|
21
|
+
def -(other) = self + -other
|
|
22
|
+
|
|
23
|
+
def to_s = "(#{self.begin}..#{self.end})"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Parser
|
|
27
|
+
DEFAULT_OPTIONS = Ractor.make_shareable({
|
|
28
|
+
context: :future, now: nil, hours24: nil, week_start: nil, guess: true, ambiguous_time_range: 6,
|
|
29
|
+
endian_precedence: %i[middle little], ambiguous_year_future_bias: 50
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
Reading = Struct.new(:begin, :end, :point, :span, :frac)
|
|
33
|
+
Notes = Struct.new(:dst)
|
|
34
|
+
NUMERIC_DATE = %r{(?<!\d)(\d{1,2})(?:[/\-.]|\s+)(\d{1,2})(?!\d)}
|
|
35
|
+
|
|
36
|
+
attr_accessor :now
|
|
37
|
+
attr_reader :options
|
|
38
|
+
|
|
39
|
+
def initialize(options = {})
|
|
40
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
41
|
+
@endian = options[:endian_precedence]
|
|
42
|
+
@now = @options[:now] || Chronic.time_class.now
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(text)
|
|
46
|
+
zone = Acute::Zone.for(zone_name)
|
|
47
|
+
result = read(text.to_s, zone) or return
|
|
48
|
+
reading = choose(result, text.to_s, zone)
|
|
49
|
+
out = guess_reading(reading, zone, @options[:guess])
|
|
50
|
+
Range === out ? Span.new(at(out.begin), at(out.end)) : at(out)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def guess(span, mode = :middle)
|
|
54
|
+
return span unless mode
|
|
55
|
+
return span.end if mode == :end
|
|
56
|
+
return span.begin unless span.width > 1 && (mode == true || mode == :middle)
|
|
57
|
+
# the middle on the wall clock, so a day with a DST change still guesses 12:00 (D46)
|
|
58
|
+
shift = span.begin.utc_offset - span.end.utc_offset
|
|
59
|
+
late = span.begin + (span.width + shift) / 2
|
|
60
|
+
late.utc_offset == span.end.utc_offset ? late : span.begin + (span.width - shift) / 2
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Chronic skips words it doesn't know, as acute does with strict: false: text with such words gives the
|
|
66
|
+
# first time mentioned in it; text acute knows every word of and still refuses ("5th tuesday in february")
|
|
67
|
+
# stays nil (D46)
|
|
68
|
+
def read(text, zone)
|
|
69
|
+
Acute.parse(text, now: @now, zone: zone.name, context: @options[:context] == :past ? :past : :future,
|
|
70
|
+
week_start: @options[:week_start], strict: false,
|
|
71
|
+
year_bias: @options[:ambiguous_year_future_bias])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Chronic's options for ambiguous text pick among acute's readings; acute's order stands where they don't
|
|
75
|
+
def choose(result, text, zone)
|
|
76
|
+
codes = result.assumptions.map(&:code)
|
|
77
|
+
clock = nil
|
|
78
|
+
all = ([result] + result.alternatives).map do |r|
|
|
79
|
+
# D47: chronic guesses inside every span but a clock time someone said ("4pm", "at 5:30"), so "next
|
|
80
|
+
# hour", one grain long and an instant to acute, is guessed too
|
|
81
|
+
long = !r.span? && r.end - r.begin > 1
|
|
82
|
+
clock = said_clock?(result.matched) if long && clock.nil?
|
|
83
|
+
span = r.span? || (long && !clock)
|
|
84
|
+
Reading.new(r.begin&.to_i, r.end&.to_i, r.time.to_i, span, r.time.subsec)
|
|
85
|
+
end
|
|
86
|
+
if codes.include?(:bare_hour_window)
|
|
87
|
+
# :none sets no window: the next (or in the past, latest) occurrence of the hour on either half of the day
|
|
88
|
+
all = if @options[:ambiguous_time_range] == :none
|
|
89
|
+
sign = @options[:context] == :past ? -1 : 1
|
|
90
|
+
all.sort_by { _1.begin.to_i * sign }
|
|
91
|
+
else
|
|
92
|
+
prefer(all) { |r| hour_window.include?(wall(r.point, zone) / 3600 % 24) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
# only when given: without it the locale decides, as D10 says
|
|
96
|
+
if @endian && codes.include?(:numeric_date_order) && (m = NUMERIC_DATE.match(text))
|
|
97
|
+
a, b = m[1].to_i, m[2].to_i
|
|
98
|
+
Array(@endian).reverse_each do |endian|
|
|
99
|
+
all = prefer(all) do |r|
|
|
100
|
+
_, month, day = civil(r.point, zone)
|
|
101
|
+
endian == :little ? [day, month] == [a, b] : [month, day] == [a, b]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
all.first
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def said_clock?(text) = clock_in?(Acute::Grammar.parse(Acute::Lexer.lex(text)))
|
|
109
|
+
|
|
110
|
+
def clock_in?(node)
|
|
111
|
+
case node
|
|
112
|
+
when Acute::Grammar::Clock then true
|
|
113
|
+
when Struct, Array then node.each.any? { clock_in?(_1) }
|
|
114
|
+
else false
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def prefer(all)
|
|
119
|
+
hits = all.select { yield _1 }
|
|
120
|
+
hits.empty? ? all : hits + (all - hits)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The hours an hour written without am/pm may mean: chronic's window from ambiguous_time_range, or the
|
|
124
|
+
# hour as written under hours24
|
|
125
|
+
def hour_window
|
|
126
|
+
range = @options[:ambiguous_time_range]
|
|
127
|
+
start = @options[:hours24] == true || range == :none ? 1 : Integer(range)
|
|
128
|
+
start...(start + 12)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Epochs, or a Range of them when guess is false, which in chronic always returns a Span; an instant keeps
|
|
132
|
+
# its written fraction of a second (D47)
|
|
133
|
+
def guess_reading(r, zone, mode)
|
|
134
|
+
return (r.begin && r.begin + r.frac)..(r.end && r.end + r.frac) unless mode || (r.begin.nil? && r.end.nil?)
|
|
135
|
+
return r.point + r.frac unless r.span && r.begin && r.end
|
|
136
|
+
return r.end if mode == :end
|
|
137
|
+
return r.begin unless mode == true || mode == :middle
|
|
138
|
+
mid = (wall(r.begin, zone) + wall(r.end, zone)) / 2
|
|
139
|
+
zone.local_to_utc(mid, Notes.new)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wall(epoch, zone) = epoch + zone.offset_at(epoch)
|
|
143
|
+
|
|
144
|
+
def civil(epoch, zone)
|
|
145
|
+
ymd = Acute::Calendar.civil_from_days(wall(epoch, zone).div(86_400))
|
|
146
|
+
[ymd / 10_000, ymd / 100 % 100, ymd % 100]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Chronic builds times in time_class: Time's local zone, or a zone like Time.zone
|
|
150
|
+
def zone_name
|
|
151
|
+
klass = Chronic.time_class
|
|
152
|
+
return klass.tzinfo if klass.respond_to?(:tzinfo)
|
|
153
|
+
return Acute::Context::SYSTEM_ZONE unless Ractor.current == Ractor.main # the cache is the main Ractor's
|
|
154
|
+
tz = ENV["TZ"]
|
|
155
|
+
cached = Parser.system_zone
|
|
156
|
+
return cached[1] if cached && cached[0] == tz
|
|
157
|
+
Parser.system_zone = [tz, Acute::Context.system_zone]
|
|
158
|
+
Parser.system_zone[1]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def at(epoch)
|
|
162
|
+
return if epoch.nil?
|
|
163
|
+
klass = Chronic.time_class
|
|
164
|
+
return klass.at(epoch) if klass.respond_to?(:tzinfo)
|
|
165
|
+
time = Time.at(epoch)
|
|
166
|
+
if klass == ::Date then time.to_date
|
|
167
|
+
elsif klass == ::DateTime then time.to_datetime
|
|
168
|
+
else time
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
class << self
|
|
173
|
+
attr_accessor :system_zone
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
require "strscan"
|
|
2
|
+
require_relative "../acute"
|
|
3
|
+
|
|
4
|
+
# chronic_duration's API, 0.10.6 and GitLab's 0.12.0 fork, on acute. Its tokeniser, number words and fixed unit
|
|
5
|
+
# lengths are kept, so answers match the gems. Where the gem would skip words and acute reads the whole text as
|
|
6
|
+
# a duration ("half an hour", "1 fortnight"), acute's reading wins, at the same fixed lengths (D46).
|
|
7
|
+
module ChronicDuration
|
|
8
|
+
extend self
|
|
9
|
+
|
|
10
|
+
class DurationParseError < StandardError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
VERSION = "0.12.0".freeze
|
|
14
|
+
FULL_WEEKS_PER_MONTH = 4
|
|
15
|
+
YEAR = 31_557_600
|
|
16
|
+
UNITS = Ractor.make_shareable(%w[seconds minutes hours days weeks months years])
|
|
17
|
+
CHRONO_UNITS = Ractor.make_shareable(UNITS - ["weeks"])
|
|
18
|
+
JOIN_WORDS = Ractor.make_shareable(%w[and with plus])
|
|
19
|
+
FLOAT = /[0-9]*\.?[0-9]+/
|
|
20
|
+
# "4:13:02" wherever it stands (D46). GitLab's 0.12 read it only as the whole text, so "2 days, 4:13:02" lost
|
|
21
|
+
# its 4:13; upstream split the whole text at colons, so "Duration: 2 hours" gained a minute.
|
|
22
|
+
CHRONO = /(?<![\d:.])\d+(?:\s*:\s*\d+){1,5}(?:\.\d*)?(?![\d:])/
|
|
23
|
+
MAPPINGS = Ractor.make_shareable({
|
|
24
|
+
"seconds" => "seconds", "second" => "seconds", "secs" => "seconds", "sec" => "seconds", "s" => "seconds",
|
|
25
|
+
"minutes" => "minutes", "minute" => "minutes", "mins" => "minutes", "min" => "minutes", "m" => "minutes",
|
|
26
|
+
"hours" => "hours", "hour" => "hours", "hrs" => "hours", "hr" => "hours", "h" => "hours",
|
|
27
|
+
"days" => "days", "day" => "days", "dy" => "days", "d" => "days",
|
|
28
|
+
"weeks" => "weeks", "week" => "weeks", "wks" => "weeks", "wk" => "weeks", "w" => "weeks",
|
|
29
|
+
"months" => "months", "mo" => "months", "mos" => "months", "month" => "months",
|
|
30
|
+
"years" => "years", "year" => "years", "yrs" => "years", "yr" => "years", "y" => "years"
|
|
31
|
+
})
|
|
32
|
+
DIVIDERS = Ractor.make_shareable({
|
|
33
|
+
micro: { years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" },
|
|
34
|
+
short: { years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s" },
|
|
35
|
+
default: { years: " yr", months: " mo", weeks: " wk", days: " day", hours: " hr", minutes: " min", seconds: " sec",
|
|
36
|
+
pluralize: true },
|
|
37
|
+
long: { years: " year", months: " month", weeks: " week", days: " day", hours: " hour", minutes: " minute",
|
|
38
|
+
seconds: " second", pluralize: true },
|
|
39
|
+
chrono: { years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
attr_accessor :hours_per_day
|
|
44
|
+
attr_reader :days_per_month, :raise_exceptions
|
|
45
|
+
attr_writer :days_per_week
|
|
46
|
+
|
|
47
|
+
def raise_exceptions=(value)
|
|
48
|
+
@raise_exceptions = !!value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# upstream sets the week's days; GitLab derives them from the month's. Whichever was set last counts (D46).
|
|
52
|
+
def days_per_week = @days_per_week || days_per_month / FULL_WEEKS_PER_MONTH
|
|
53
|
+
|
|
54
|
+
def days_per_month=(value)
|
|
55
|
+
@days_per_month = value
|
|
56
|
+
@days_per_week = nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
self.raise_exceptions = false
|
|
60
|
+
self.hours_per_day = 24
|
|
61
|
+
self.days_per_month = 30
|
|
62
|
+
|
|
63
|
+
def parse(string, opts = {})
|
|
64
|
+
# T3: past the 512 bytes acute reads, before the rewrites rather than after them
|
|
65
|
+
if string.bytesize > Acute::MAX_LENGTH
|
|
66
|
+
raise DurationParseError, "The string is longer than #{Acute::MAX_LENGTH} bytes." if ChronicDuration.raise_exceptions
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
words, skipped = clean(string)
|
|
70
|
+
result = acute_seconds(string, opts) unless skipped.empty?
|
|
71
|
+
if result.nil?
|
|
72
|
+
if !skipped.empty? && ChronicDuration.raise_exceptions
|
|
73
|
+
raise DurationParseError, "An invalid word #{skipped[0].inspect} was used in the string to be parsed."
|
|
74
|
+
end
|
|
75
|
+
result = calculate_from_words(words, opts)
|
|
76
|
+
end
|
|
77
|
+
!opts[:keep_zero] && result == 0 ? nil : result
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def output(seconds, opts = {})
|
|
81
|
+
int = seconds.to_i
|
|
82
|
+
seconds = int if seconds - int == 0
|
|
83
|
+
style = opts[:format] || :default
|
|
84
|
+
hours_per_day, days_per_month, days_per_week = settings(opts)
|
|
85
|
+
years = months = weeks = days = hours = minutes = 0
|
|
86
|
+
decimal_places = seconds.to_s.split(".").last.length if seconds.is_a?(Float)
|
|
87
|
+
|
|
88
|
+
day = hours_per_day * 3600
|
|
89
|
+
month = days_per_month * day
|
|
90
|
+
if seconds >= YEAR && seconds % YEAR < seconds % month
|
|
91
|
+
years = seconds / YEAR
|
|
92
|
+
months = seconds % YEAR / month
|
|
93
|
+
days = seconds % YEAR % month / day
|
|
94
|
+
hours = seconds % YEAR % month % day / 3600
|
|
95
|
+
minutes = seconds % YEAR % month % day % 3600 / 60
|
|
96
|
+
seconds = seconds % YEAR % month % day % 3600 % 60
|
|
97
|
+
elsif seconds >= 60
|
|
98
|
+
minutes = (seconds / 60).to_i
|
|
99
|
+
seconds %= 60
|
|
100
|
+
if minutes >= 60
|
|
101
|
+
hours = (minutes / 60).to_i
|
|
102
|
+
minutes = (minutes % 60).to_i
|
|
103
|
+
if !opts[:limit_to_hours] && hours >= hours_per_day
|
|
104
|
+
days = (hours / hours_per_day).to_i
|
|
105
|
+
hours = (hours % hours_per_day).to_i
|
|
106
|
+
if opts[:weeks]
|
|
107
|
+
if days >= days_per_week
|
|
108
|
+
weeks = (days / days_per_week).to_i
|
|
109
|
+
days = (days % days_per_week).to_i
|
|
110
|
+
if weeks >= FULL_WEEKS_PER_MONTH
|
|
111
|
+
months = (weeks / FULL_WEEKS_PER_MONTH).to_i
|
|
112
|
+
weeks = (weeks % FULL_WEEKS_PER_MONTH).to_i
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
elsif days >= days_per_month
|
|
116
|
+
months = (days / days_per_month).to_i
|
|
117
|
+
days = (days % days_per_month).to_i
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
dividers = DIVIDERS[style]
|
|
124
|
+
joiner = %i[micro chrono].include?(style) ? "" : opts.fetch(:joiner, " ")
|
|
125
|
+
counts = { years: years, months: months, weeks: weeks, days: days, hours: hours, minutes: minutes, seconds: seconds }
|
|
126
|
+
# the gem's compact! returned nil when no unit was dropped, so weeks: true with every unit shown raised (D46)
|
|
127
|
+
result = counts.filter_map do |unit, num|
|
|
128
|
+
next if unit == :weeks && !opts[:weeks]
|
|
129
|
+
num = format("%.#{decimal_places}f", num) if num.is_a?(Float) && unit == :seconds
|
|
130
|
+
keep_zero = dividers[:keep_zero] || (opts[:keep_zero] if unit == :seconds)
|
|
131
|
+
humanize_time_unit(num, dividers[unit], dividers[:pluralize], keep_zero)
|
|
132
|
+
end
|
|
133
|
+
result = result[0...opts[:units]] if opts[:units]
|
|
134
|
+
result = result.join(joiner)
|
|
135
|
+
result = chrono(result, decimal_places) if style == :chrono
|
|
136
|
+
result.empty? ? nil : result
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def humanize_time_unit(number, unit, pluralize, keep_zero)
|
|
142
|
+
return if number == 0 && !keep_zero
|
|
143
|
+
res = "#{number}#{unit}"
|
|
144
|
+
res << "s" if number != 1 && pluralize
|
|
145
|
+
res
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Pad each part to two digits, then drop leading zero parts, a leading zero and the trailing colon
|
|
149
|
+
def chrono(str, decimal_places)
|
|
150
|
+
str.split(":").map { |n| n.include?(".") ? format("%04.#{decimal_places}f", n) : format("%02d", n) }
|
|
151
|
+
.join(":").gsub(/^(00:)+/, "").gsub(/^0/, "").gsub(/:$/, "")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Per-call options over the module's settings; GitLab's per-call days_per_month also sets the week
|
|
155
|
+
def settings(opts)
|
|
156
|
+
hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day
|
|
157
|
+
days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month
|
|
158
|
+
days_per_week = opts[:days_per_week] ||
|
|
159
|
+
(opts[:days_per_month] ? days_per_month / FULL_WEEKS_PER_MONTH : ChronicDuration.days_per_week)
|
|
160
|
+
[hours_per_day, days_per_month, days_per_week]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def lengths(opts)
|
|
164
|
+
hours_per_day, days_per_month, days_per_week = settings(opts)
|
|
165
|
+
day = 3600 * hours_per_day
|
|
166
|
+
{ "years" => YEAR, "months" => day * days_per_month, "weeks" => day * days_per_week, "days" => day,
|
|
167
|
+
"hours" => 3600, "minutes" => 60, "seconds" => 1 }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def calculate_from_words(string, opts)
|
|
171
|
+
seconds = lengths(opts)
|
|
172
|
+
words = string.split
|
|
173
|
+
words.each_with_index.sum do |v, k|
|
|
174
|
+
v.match?(FLOAT) ? convert_to_number(v) * seconds.fetch(words[k + 1] || opts[:default_unit] || "seconds", 0) : 0
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# acute's reading of the whole text, each unit said at the gem's fixed lengths, so "2.5 weeks" of 5-day weeks
|
|
179
|
+
# is 12.5 days. A business day has no such length, so the gem's answer stands. A fraction anywhere makes a
|
|
180
|
+
# Float, as the gem's own decimals do.
|
|
181
|
+
def acute_seconds(string, opts)
|
|
182
|
+
tokens = Acute::Lexer.lex(string)
|
|
183
|
+
return if Acute::Failure === tokens
|
|
184
|
+
units = Acute::Grammar.duration(tokens)
|
|
185
|
+
return if Acute::Failure === units
|
|
186
|
+
parts = units.map { |u| [*Acute::Duration::UNIT_PART.fetch(u.unit), u.count] }
|
|
187
|
+
return if parts.any? { |part, *| part == :business_days }
|
|
188
|
+
seconds = lengths(opts)
|
|
189
|
+
total = parts.sum { |part, k, count| count * k * seconds.fetch(part.to_s) }
|
|
190
|
+
parts.all? { |*, count| count.to_r.denominator == 1 } ? total.to_i : total.to_f
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def cleanup(string) = clean(string)[0]
|
|
194
|
+
|
|
195
|
+
def clean(string)
|
|
196
|
+
res = filter_by_type(Numerizer.numerize(string.downcase))
|
|
197
|
+
res = res.gsub(FLOAT) { " #{_1} " }.squeeze(" ").strip
|
|
198
|
+
filter_through_white_list(res)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def convert_to_number(string) = string.to_f % 1 > 0 ? string.to_f : string.to_i
|
|
202
|
+
|
|
203
|
+
# "3:41:59" is 3 hours 41 minutes 59 seconds
|
|
204
|
+
def filter_by_type(string)
|
|
205
|
+
string.gsub(CHRONO) do |chrono|
|
|
206
|
+
chrono.delete(" ").split(":").reverse.each_with_index.map { |v, k| "#{v} #{CHRONO_UNITS[k]}" }.reverse.join(" ")
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# The units named, with numbers kept and other words skipped; the skipped words come back too
|
|
211
|
+
def filter_through_white_list(string)
|
|
212
|
+
res = []
|
|
213
|
+
skipped = []
|
|
214
|
+
string.split.each do |word|
|
|
215
|
+
next res << word.strip if word.match?(FLOAT)
|
|
216
|
+
stripped = word.strip.delete_prefix(",").delete_suffix(",")
|
|
217
|
+
if MAPPINGS.key?(stripped) then res << MAPPINGS[stripped]
|
|
218
|
+
elsif !JOIN_WORDS.include?(stripped) then skipped << word
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
# "day" or "minute 30sec": a unit first counts one
|
|
222
|
+
res.unshift(1) if !res.empty? && MAPPINGS[res[0]]
|
|
223
|
+
[res.join(" "), skipped]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# numerizer 0.2.0, which both gems use for number words
|
|
227
|
+
module Numerizer
|
|
228
|
+
DIRECT_NUMS = [
|
|
229
|
+
["eleven", "11"], ["twelve", "12"], ["thirteen", "13"], ["fourteen", "14"], ["fifteen", "15"], ["sixteen", "16"],
|
|
230
|
+
["seventeen", "17"], ["eighteen", "18"], ["nineteen", "19"], ["ninteen", "19"], ["zero", "0"], ["ten", "10"],
|
|
231
|
+
['\ba[\b^$]', "1"]
|
|
232
|
+
].freeze
|
|
233
|
+
SINGLE_NUMS = [
|
|
234
|
+
["one", 1], ["two", 2], ["three", 3], ["four", 4], ["five", 5], ["six", 6], ["seven", 7],
|
|
235
|
+
["eight", 8], ["nine", 9]
|
|
236
|
+
].freeze
|
|
237
|
+
TEN_PREFIXES = [
|
|
238
|
+
["twenty", 20], ["thirty", 30], ["forty", 40], ["fourty", 40], ["fifty", 50], ["sixty", 60],
|
|
239
|
+
["seventy", 70], ["eighty", 80], ["ninety", 90]
|
|
240
|
+
].freeze
|
|
241
|
+
BIG_PREFIXES = [
|
|
242
|
+
["hundred", 100], ["thousand", 1000], ["million", 1_000_000], ["billion", 1_000_000_000],
|
|
243
|
+
["trillion", 1_000_000_000_000]
|
|
244
|
+
].freeze
|
|
245
|
+
FRACTIONS = [
|
|
246
|
+
["half", 2], ["third(s)?", 3], ["fourth(s)?", 4], ["quarter(s)?", 4], ["fifth(s)?", 5],
|
|
247
|
+
["sixth(s)?", 6], ["seventh(s)?", 7], ["eighth(s)?", 8], ["nineth(s)?", 9]
|
|
248
|
+
].freeze
|
|
249
|
+
SINGLE_ORDINALS = [
|
|
250
|
+
["first", 1], ["third", 3], ["fourth", 4], ["fifth", 5], ["sixth", 6], ["seventh", 7],
|
|
251
|
+
["eighth", 8], ["ninth", 9]
|
|
252
|
+
].freeze
|
|
253
|
+
DIRECT_ORDINALS = [
|
|
254
|
+
["tenth", "10"], ["eleventh", "11"], ["twelfth", "12"], ["thirteenth", "13"], ["fourteenth", "14"],
|
|
255
|
+
["fifteenth", "15"], ["sixteenth", "16"], ["seventeenth", "17"], ["eighteenth", "18"], ["nineteenth", "19"],
|
|
256
|
+
["twentieth", "20"], ["thirtieth", "30"], ["fourtieth", "40"], ["fiftieth", "50"], ["sixtieth", "60"],
|
|
257
|
+
["seventieth", "70"], ["eightieth", "80"], ["ninetieth", "90"]
|
|
258
|
+
].freeze
|
|
259
|
+
# The inner pairs too, so a Ractor can read them
|
|
260
|
+
[DIRECT_NUMS, SINGLE_NUMS, TEN_PREFIXES, BIG_PREFIXES, FRACTIONS, SINGLE_ORDINALS, DIRECT_ORDINALS].each { Ractor.make_shareable(_1) }
|
|
261
|
+
|
|
262
|
+
module_function
|
|
263
|
+
|
|
264
|
+
def numerize(string)
|
|
265
|
+
string = string.dup
|
|
266
|
+
string.gsub!(/ +|([^\d])-([^\d])/, '\1 \2')
|
|
267
|
+
(DIRECT_NUMS + SINGLE_NUMS).each do |word, n|
|
|
268
|
+
string.gsub!(/(^|\W)#{word}(?=$|\W)/i, "\\1<num>#{n}")
|
|
269
|
+
end
|
|
270
|
+
TEN_PREFIXES.each do |tp, t|
|
|
271
|
+
SINGLE_NUMS.each { |dn, d| string.gsub!(/(^|\W)#{tp}#{dn}(?=$|\W)/i, "\\1<num>#{t + d}") }
|
|
272
|
+
SINGLE_ORDINALS.each { |dn, d| string.gsub!(/(^|\W)#{tp}(\s)?#{dn}(?=$|\W)/i, "\\1<num>#{t + d}#{dn[-2, 2]}") }
|
|
273
|
+
string.gsub!(/(^|\W)#{tp}(?=$|\W)/i, "\\1<num>#{t}")
|
|
274
|
+
end
|
|
275
|
+
FRACTIONS.each do |word, d|
|
|
276
|
+
string.gsub!(/a #{word}(?=$|\W)/i, "<num>1/#{d}")
|
|
277
|
+
string.gsub!(/\s#{word}(?=$|\W)/i, "/#{d}")
|
|
278
|
+
end
|
|
279
|
+
(DIRECT_ORDINALS + SINGLE_ORDINALS).each do |word, n|
|
|
280
|
+
string.gsub!(/(^|\W)#{word}(?=$|\W)/i, "\\1<num>#{n}#{word[-2, 2]}")
|
|
281
|
+
end
|
|
282
|
+
# a fraction after a number adds to it
|
|
283
|
+
string.gsub!(%r{(\d+)(?: | and |-)+(<num>|\s)*(\d+)\s*/\s*(\d+)}i) { ($1.to_f + ($3.to_f / $4.to_f)).to_s }
|
|
284
|
+
BIG_PREFIXES.each do |word, n|
|
|
285
|
+
string.gsub!(/(?:<num>)?(\d*) *#{word}/i) { $1.empty? ? n.to_s : "<num>#{n * $1.to_i}" }
|
|
286
|
+
andition(string)
|
|
287
|
+
end
|
|
288
|
+
andition(string)
|
|
289
|
+
string.gsub("<num>", "")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# "<num>20 <num>4" is 24, and "<num>100 and <num>5" 105
|
|
293
|
+
def andition(string)
|
|
294
|
+
sc = StringScanner.new(string)
|
|
295
|
+
while sc.scan_until(/<num>(\d+)( | and )<num>(\d+)(?=[^\w]|$)/i)
|
|
296
|
+
next unless sc[2].include?("and") || sc[1].size > sc[3].size
|
|
297
|
+
string[(sc.pos - sc.matched_size)..(sc.pos - 1)] = "<num>#{sc[1].to_i + sc[3].to_i}"
|
|
298
|
+
sc.reset
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
private_constant :Numerizer
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Ported from chronic_duration 0.10.6 and gitlab_chronic_duration 0.12.0, and from numerizer 0.2.0:
|
|
306
|
+
#
|
|
307
|
+
# Copyright (c) Henry Poydar (chronic_duration); Copyright (c) 2008 Tom Preston-Werner (numerizer)
|
|
308
|
+
#
|
|
309
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
310
|
+
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation
|
|
311
|
+
# the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
|
|
312
|
+
# permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
313
|
+
#
|
|
314
|
+
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of
|
|
315
|
+
# the Software.
|
|
316
|
+
#
|
|
317
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
|
318
|
+
# THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
319
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
320
|
+
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
321
|
+
# SOFTWARE.
|