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
data/lib/acute/cli.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require_relative "../acute"
|
|
3
|
+
|
|
4
|
+
module Acute
|
|
5
|
+
# exe/acute: one phrase from the arguments, or one per line from stdin (D45)
|
|
6
|
+
# @api private
|
|
7
|
+
class CLI
|
|
8
|
+
CLOCK_GRAINS = %i[second minute hour].freeze
|
|
9
|
+
# the library's DST hints name its Ruby option; the command's name its flag
|
|
10
|
+
DST_HINTS = { dst_skipped_time: "--dst earlier, later or compatible picks an instant for it",
|
|
11
|
+
dst_repeated_time: "--dst earlier or later picks one" }.freeze
|
|
12
|
+
|
|
13
|
+
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr) = new(stdin, stdout, stderr).run(argv)
|
|
14
|
+
|
|
15
|
+
def initialize(stdin, stdout, stderr)
|
|
16
|
+
@stdin = stdin
|
|
17
|
+
@out = stdout
|
|
18
|
+
@err = stderr
|
|
19
|
+
@json = false
|
|
20
|
+
@next = nil
|
|
21
|
+
@options = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(argv)
|
|
25
|
+
return mcp(argv.drop(1)) if argv.first == "mcp"
|
|
26
|
+
args = options.parse(argv)
|
|
27
|
+
return say(@say) if @say
|
|
28
|
+
@parser = Acute.with(**@options)
|
|
29
|
+
return phrase(args.join(" ")) if args.any?
|
|
30
|
+
return usage if @stdin.tty?
|
|
31
|
+
@out.sync = true
|
|
32
|
+
@stdin.each_line.map { _1.b.strip.empty? || line(_1.chomp) }.all? ? 0 : 1
|
|
33
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
34
|
+
@err.puts "acute: #{Acute.visible(e.message.scrub)}"
|
|
35
|
+
2
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def options
|
|
41
|
+
OptionParser.new do |o|
|
|
42
|
+
o.banner = "Usage: acute [options] PHRASE\n acute [options] < phrases.txt (one per line)\n " \
|
|
43
|
+
"acute mcp [options] (an MCP server: acute mcp --help)"
|
|
44
|
+
o.on("--json", "Print JSON (JSON Lines for stdin)") { @json = true }
|
|
45
|
+
o.on("--next N", Integer, "A rule's first N occurrences, 1–#{Rule::MAX_OCCURRENCES}") do |n|
|
|
46
|
+
raise OptionParser::InvalidArgument, n.to_s unless (1..Rule::MAX_OCCURRENCES).cover?(n)
|
|
47
|
+
@next = n
|
|
48
|
+
end
|
|
49
|
+
o.on("--now TIME", "Now, in ISO 8601 (default: the clock)") { @options[:now] = _1 }
|
|
50
|
+
o.on("--zone ZONE", "IANA zone or offset (default: the system's)") { @options[:zone] = _1 }
|
|
51
|
+
o.on("--locale LOCALE", "en-US, en-GB, en-AU… (default: from the zone)") { @options[:locale] = _1 }
|
|
52
|
+
o.on("--context CONTEXT", %w[future past], "future or past") { @options[:context] = _1.to_sym }
|
|
53
|
+
o.on("--week-start DAY", %w[sunday monday], "sunday or monday") { @options[:week_start] = _1.to_sym }
|
|
54
|
+
o.on("--hemisphere SIDE", %w[north south], "north or south, for seasons") { @options[:hemisphere] = _1.to_sym }
|
|
55
|
+
o.on("--fiscal-year-start MONTH", Integer, "1–12") { @options[:fiscal_year_start] = _1 }
|
|
56
|
+
o.on("--lenient", "Skip unknown words (strict: false)") { @options[:strict] = false }
|
|
57
|
+
o.on("--dst POLICY", %w[compatible earlier later reject], "compatible, earlier, later or reject") { @options[:dst] = _1.to_sym }
|
|
58
|
+
o.on("--holidays REGION", "A holidays-gem region, such as us (needs the gem)") { @options[:holidays] = _1 }
|
|
59
|
+
o.on("--year-bias YEARS", Integer, "Two-digit years: the 100 from this many years ago (default 50)") { @options[:year_bias] = _1 }
|
|
60
|
+
o.on("-v", "--version") { @say = Acute::VERSION }
|
|
61
|
+
o.on("-h", "--help") { @say = o.to_s }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The options an operator fixes for every call. now is always the clock; the model may pass zone per call.
|
|
66
|
+
def mcp(argv)
|
|
67
|
+
options = {}
|
|
68
|
+
parser = OptionParser.new do |o|
|
|
69
|
+
o.banner = "Usage: acute mcp [options]\n" \
|
|
70
|
+
"Serves acute's tools to an MCP client over stdio: one JSON-RPC message per line."
|
|
71
|
+
o.on("--zone ZONE", "IANA zone or offset when a call names none (default: the system's)") { options[:zone] = _1 }
|
|
72
|
+
o.on("--locale LOCALE", "en-US, en-GB, en-AU… (default: from the zone)") { options[:locale] = _1 }
|
|
73
|
+
o.on("--context CONTEXT", %w[future past], "Default for calls that give none: future or past") { options[:context] = _1.to_sym }
|
|
74
|
+
o.on("--week-start DAY", %w[sunday monday], "sunday or monday") { options[:week_start] = _1.to_sym }
|
|
75
|
+
o.on("-h", "--help") { return say(o.to_s) }
|
|
76
|
+
end
|
|
77
|
+
extra = parser.parse(argv)
|
|
78
|
+
raise OptionParser::NeedlessArgument, extra.join(" ") if extra.any?
|
|
79
|
+
require_relative "mcp"
|
|
80
|
+
server = MCP.new(Tool.new(**options), log: @err)
|
|
81
|
+
@stdin.binmode
|
|
82
|
+
server.serve(@stdin, @out)
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def say(text)
|
|
87
|
+
@out.puts text
|
|
88
|
+
0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def usage
|
|
92
|
+
@err.puts options
|
|
93
|
+
2
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A single phrase that fails exits 1 with parse!'s reason
|
|
97
|
+
def phrase(text)
|
|
98
|
+
value = reading(text)
|
|
99
|
+
return show(value) || 0 if value
|
|
100
|
+
@parser.parse!(text)
|
|
101
|
+
rescue ParseError => e
|
|
102
|
+
@json ? @out.puts(failure(e, text)) : @err.puts("acute: #{e.message}")
|
|
103
|
+
1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def line(text)
|
|
107
|
+
value = reading(text)
|
|
108
|
+
return show(value, batch: true) || true if value
|
|
109
|
+
@parser.parse!(text)
|
|
110
|
+
rescue ParseError => e
|
|
111
|
+
@out.puts @json ? failure(e, text) : "error: #{e.message}"
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# schema/failure.json, as the tools give it, plus the input line, which a tool never echoes. Like the tools, a
|
|
116
|
+
# position over the whole phrase is left out.
|
|
117
|
+
def failure(e, text)
|
|
118
|
+
text = text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
119
|
+
json = { kind: "failure", text:, code: e.code.to_s, error: e.message }
|
|
120
|
+
json[:position] = [e.position.begin, e.position.end] unless whole?(e.position, text)
|
|
121
|
+
json[:hint] = DST_HINTS.fetch(e.code, e.hint) if e.hint
|
|
122
|
+
JSON.generate(json)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def whole?(span, text) = text[0...span.begin].strip.empty? && text[span.end..].to_s.strip.empty?
|
|
126
|
+
|
|
127
|
+
# --next asks for a rule, so a phrase that isn't one fails with recurrence!'s reason
|
|
128
|
+
def reading(text) = @next ? @parser.recurrence!(text) : @parser.read(text)
|
|
129
|
+
|
|
130
|
+
# A rule is several lines, joined into one for stdin's one line per phrase
|
|
131
|
+
def show(value, batch: false)
|
|
132
|
+
return @out.puts(@json ? value.to_json : echo(value)) unless Rule === value
|
|
133
|
+
at = @next && occurrences(value)
|
|
134
|
+
return @out.puts(at ? value.to_h.merge(next: at.map(&:iso8601)).to_json : value.to_json) if @json
|
|
135
|
+
lines = [value.to_s, *value.to_ical.lines(chomp: true)]
|
|
136
|
+
lines << "cron: #{value.cron} (CRON_TZ=#{value.zone})" if value.cron
|
|
137
|
+
lines.concat(at.map { occurrence(_1, value.zone) }) if at
|
|
138
|
+
@out.puts batch ? lines.join(" ") : lines
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The rule's first n occurrences, DTSTART's included: the Date before it starts the search
|
|
142
|
+
def occurrences(rule) = rule.next!(@next, after: rule.dtstart.to_date - 1)
|
|
143
|
+
|
|
144
|
+
def occurrence(at, zone)
|
|
145
|
+
text = @parser.describe(at, zone: zone, style: :calendar)
|
|
146
|
+
Time === at ? "#{text} #{Zone.for(zone).abbreviation_at(at.to_i)}" : text
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# "Fri, Oct 23, 3pm EDT (also: Fri, Oct 16, 3pm)"
|
|
150
|
+
def echo(value)
|
|
151
|
+
return @parser.describe(value) if Duration === value
|
|
152
|
+
text = described(value)
|
|
153
|
+
return text unless value.ambiguous?
|
|
154
|
+
said = abbreviation(value)
|
|
155
|
+
"#{text} (also: #{value.alternatives.map { described(_1, said) }.join("; ")})"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A time of day names its zone, unless describe already did or the echo already has
|
|
159
|
+
def described(r, said = nil)
|
|
160
|
+
text = @parser.describe(r, zone: r.zone, style: :calendar)
|
|
161
|
+
abbr = abbreviation(r)
|
|
162
|
+
return text if abbr.nil? || abbr == said || text.end_with?(abbr) || text.match?(/[+-]\d\d:\d\d\z/)
|
|
163
|
+
"#{text} #{abbr}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def abbreviation(r) = CLOCK_GRAINS.include?(r.grain) ? Zone.for(r.zone).abbreviation_at(r.time.to_i) : nil
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
require_relative "calendar"
|
|
3
|
+
require_relative "zone"
|
|
4
|
+
require_relative "assumption"
|
|
5
|
+
require_relative "locale"
|
|
6
|
+
require_relative "holidays"
|
|
7
|
+
|
|
8
|
+
module Acute
|
|
9
|
+
# Everything a parse depends on besides the text: now, the zone and the direction. It also collects the
|
|
10
|
+
# assumptions the resolver makes, so it is built fresh for every parse.
|
|
11
|
+
# @api private
|
|
12
|
+
class Context
|
|
13
|
+
ISO = /\A(\d{4})-(\d\d)-(\d\d)(?:[T ](\d\d):(\d\d)(?::(\d\d)(?:\.\d+)?)?)?(Z|[+-]\d\d:?\d\d)?\z/
|
|
14
|
+
|
|
15
|
+
# TZ may start with POSIX's ":", and /etc/localtime may link into posix/ or right/, or to a zone the shipped
|
|
16
|
+
# data doesn't hold: only a name acute ships is used, so a parse without zone: never raises for it
|
|
17
|
+
def self.system_zone(tz = ENV["TZ"], link = "/etc/localtime")
|
|
18
|
+
tz = tz&.delete_prefix(":")
|
|
19
|
+
return tz if tz && Zone.known?(tz)
|
|
20
|
+
name = File.readlink(link)[%r{zoneinfo/(?:posix/|right/)?(.+)\z}, 1]
|
|
21
|
+
name && Zone.known?(name) ? name : "UTC"
|
|
22
|
+
rescue SystemCallError
|
|
23
|
+
"UTC"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
SYSTEM_ZONE = system_zone.freeze
|
|
27
|
+
ZONE_INFERRED = Assumption.for(:zone_inferred)
|
|
28
|
+
NO_NOTES = [].freeze
|
|
29
|
+
|
|
30
|
+
WEEK_STARTS = { sunday: 0, monday: 1 }.freeze
|
|
31
|
+
HEMISPHERES = { north: false, south: true }.freeze
|
|
32
|
+
DST_POLICIES = { compatible: nil, earlier: :earlier, later: :later, reject: :reject }.freeze
|
|
33
|
+
YEAR_BIAS = 50
|
|
34
|
+
|
|
35
|
+
attr_reader :now, :now_local, :zone, :today, :now_secs, :notes, :fiscal_year_start, :strict, :dst_policy,
|
|
36
|
+
:holidays, :year_bias
|
|
37
|
+
# past: true in past context; :back while resolve looks back for the most recent one, which adds no
|
|
38
|
+
# alternatives of its own (D44)
|
|
39
|
+
attr_accessor :dst, :past, :machine
|
|
40
|
+
# D47: the fraction of a second a clock in the text was written with
|
|
41
|
+
attr_accessor :frac
|
|
42
|
+
|
|
43
|
+
# The zone a TimeWithZone, or a Time made with a TZInfo zone, brings with it
|
|
44
|
+
def self.carried_zone(now)
|
|
45
|
+
if now.respond_to?(:time_zone) then now.time_zone.tzinfo
|
|
46
|
+
elsif now.is_a?(Time) && Zone.tzinfo?(now.zone) then now.zone
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(now, zone, context, locale = nil, week_start = nil, hemisphere = nil, fiscal_year_start = nil,
|
|
51
|
+
strict = true, dst = nil, holidays = nil, business_days = nil, year_bias = nil)
|
|
52
|
+
@past = case context
|
|
53
|
+
when :future then false
|
|
54
|
+
when :past then true
|
|
55
|
+
else raise OptionError.new("context: must be :future or :past", option: :context, value: context)
|
|
56
|
+
end
|
|
57
|
+
if zone
|
|
58
|
+
@zone = Zone.for(zone)
|
|
59
|
+
else
|
|
60
|
+
@zone = Zone.for(Context.carried_zone(now) || rails_zone || SYSTEM_ZONE)
|
|
61
|
+
@inferred = true
|
|
62
|
+
end
|
|
63
|
+
@now = Time === now ? now.to_i : epoch(now)
|
|
64
|
+
self.zone = @zone
|
|
65
|
+
@locale_zone = @zone.name
|
|
66
|
+
@strict = strict
|
|
67
|
+
@year_bias = YEAR_BIAS
|
|
68
|
+
# Most parses give none of the other options, and then skip their checks
|
|
69
|
+
return unless locale || week_start || hemisphere || fiscal_year_start || strict != true || dst || holidays || business_days || year_bias
|
|
70
|
+
options(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays, business_days, year_bias)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def fresh = Context.new(*@args).tap { _1.budget = budget }
|
|
74
|
+
|
|
75
|
+
# D49: the business days this call may still count, shared with the contexts it makes for each piece (T17)
|
|
76
|
+
attr_writer :budget
|
|
77
|
+
|
|
78
|
+
def budget = @budget ||= Resolve::Period::Budget.new
|
|
79
|
+
|
|
80
|
+
# D47: the days business-day counts step over, or nil for Monday to Friday. The built-in holidays only name
|
|
81
|
+
# days; a holidays: given is taken as days off.
|
|
82
|
+
def business_days = @business_days || (@holidays && (budget.workdays ||= Holidays.workdays(@holidays)))
|
|
83
|
+
|
|
84
|
+
# Under a zone named in the text, today and now_secs move with it; returns the block's value
|
|
85
|
+
def in_zone(zone)
|
|
86
|
+
home = @zone
|
|
87
|
+
now_local = @now_local
|
|
88
|
+
self.zone = zone
|
|
89
|
+
out = yield
|
|
90
|
+
@zone = home
|
|
91
|
+
@now_local = now_local
|
|
92
|
+
@today = now_local / 86_400
|
|
93
|
+
@now_secs = now_local - @today * 86_400
|
|
94
|
+
out
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private def zone=(zone)
|
|
98
|
+
@zone = zone
|
|
99
|
+
@now_local = local = @now + zone.offset_at(@now)
|
|
100
|
+
@today = local / 86_400
|
|
101
|
+
@now_secs = local - @today * 86_400
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The wall time of an instant, in local seconds
|
|
105
|
+
def local(utc) = utc + @zone.offset_at(utc)
|
|
106
|
+
|
|
107
|
+
# Found only when a phrase needs it, from the zone given rather than one named in the text
|
|
108
|
+
def locale = @locale ||= Locale.for_zone(@locale_zone)
|
|
109
|
+
|
|
110
|
+
def week_start = @week_start ||= locale.week_start
|
|
111
|
+
|
|
112
|
+
def south = @south.nil? ? (@south = locale.south) : @south
|
|
113
|
+
|
|
114
|
+
def past? = @past
|
|
115
|
+
|
|
116
|
+
def note(assumption)
|
|
117
|
+
if @notes
|
|
118
|
+
@notes << assumption unless @notes.include?(assumption)
|
|
119
|
+
else
|
|
120
|
+
@notes = [assumption]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def unnote(assumption) = @notes&.delete(assumption)
|
|
125
|
+
|
|
126
|
+
# The main reading's assumptions: the rules applied, then its DST adjustment, then the zone
|
|
127
|
+
def assumptions(main)
|
|
128
|
+
dst = main.dst unless @dst_policy # D47: a policy the caller chose is not an assumption
|
|
129
|
+
return NO_NOTES unless @notes || dst || @inferred
|
|
130
|
+
list = @notes || []
|
|
131
|
+
list << Assumption.for(dst) if dst
|
|
132
|
+
list << ZONE_INFERRED if @inferred
|
|
133
|
+
list.freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def options(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays, business_days, year_bias)
|
|
139
|
+
raise OptionError.new("strict: must be true or false", option: :strict, value: strict) unless strict == true || strict == false
|
|
140
|
+
@locale = Locale.for(locale) if locale
|
|
141
|
+
@week_start = week_start && choice(:week_start, week_start, WEEK_STARTS, ":sunday or :monday")
|
|
142
|
+
@south = hemisphere && choice(:hemisphere, hemisphere, HEMISPHERES, ":north or :south")
|
|
143
|
+
@fiscal_year_start = fiscal_year_start && whole(:fiscal_year_start, fiscal_year_start, 1..12, "a month, 1 to 12")
|
|
144
|
+
unless strict
|
|
145
|
+
# Lenient parsing resolves each piece it tries in a fresh context
|
|
146
|
+
@args = [now || Time.at(@now), zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst,
|
|
147
|
+
holidays, business_days, year_bias]
|
|
148
|
+
end
|
|
149
|
+
@dst_policy = dst && choice(:dst, dst, DST_POLICIES, ":compatible, :earlier, :later or :reject")
|
|
150
|
+
@holidays = holidays && Holidays.for(holidays)
|
|
151
|
+
if business_days
|
|
152
|
+
unless business_days.respond_to?(:business_day?)
|
|
153
|
+
raise OptionError.new("business_days: must answer business_day?(date)", option: :business_days,
|
|
154
|
+
value: business_days)
|
|
155
|
+
end
|
|
156
|
+
@business_days = business_days
|
|
157
|
+
end
|
|
158
|
+
@year_bias = whole(:year_bias, year_bias, 0..99, "a number of years, 0 to 99") if year_bias
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def choice(option, value, table, allowed)
|
|
162
|
+
key = (Symbol === value || (String === value && value.valid_encoding?)) && value.to_sym
|
|
163
|
+
table.fetch(key) { raise OptionError.new("#{option}: must be #{allowed}", option: option, value: value) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def whole(option, value, range, allowed)
|
|
167
|
+
n = Integer === value ? value : (String === value && Integer(value, 10, exception: false))
|
|
168
|
+
return n if n && range.cover?(n)
|
|
169
|
+
raise OptionError.new("#{option}: must be #{allowed}", option: option, value: value)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rails_zone
|
|
173
|
+
Time.zone&.tzinfo if defined?(ActiveSupport::TimeZone) && Time.respond_to?(:zone)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def epoch(now)
|
|
177
|
+
case now
|
|
178
|
+
when nil then Time.now.to_i
|
|
179
|
+
when Time then now.to_i
|
|
180
|
+
when DateTime then now.to_time.to_i
|
|
181
|
+
when Date then @zone.local_to_utc((now.jd - 2_440_588) * 86_400, self)
|
|
182
|
+
when String then iso_epoch(now)
|
|
183
|
+
else
|
|
184
|
+
return now.to_i if now.respond_to?(:time_zone)
|
|
185
|
+
raise OptionError.new("now: must be a Time, Date or ISO 8601 String", option: :now, value: now)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def iso_epoch(str)
|
|
190
|
+
m = str.valid_encoding? && ISO.match(str) or raise OptionError.new("now: #{Acute.quote(str)} is not ISO 8601", option: :now, value: str)
|
|
191
|
+
y, mo, d, h, mi, s = m.captures.first(6).map(&:to_i)
|
|
192
|
+
offset = m[7]&.delete(":")
|
|
193
|
+
# an offset is within ±23:59, as a zone's is
|
|
194
|
+
unless mo.between?(1, 12) && d.between?(1, Calendar.days_in_month(y, mo)) && h < 24 && mi < 60 && s < 60 &&
|
|
195
|
+
(offset.nil? || offset == "Z" || (offset[1, 2].to_i < 24 && offset[3, 2].to_i < 60))
|
|
196
|
+
raise OptionError.new("now: #{Acute.quote(str)} is not a valid time", option: :now, value: str)
|
|
197
|
+
end
|
|
198
|
+
local = Calendar.days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s
|
|
199
|
+
return @zone.local_to_utc(local, self) unless offset
|
|
200
|
+
return local if offset == "Z"
|
|
201
|
+
sign = offset.start_with?("-") ? -1 : 1
|
|
202
|
+
local - sign * (offset[1, 2].to_i * 3600 + offset[3, 2].to_i * 60)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
def self.describe(value, now: nil, zone: nil, context: :future, locale: nil, week_start: nil, hemisphere: nil,
|
|
3
|
+
fiscal_year_start: nil, strict: true, dst: nil, holidays: nil, business_days: nil, year_bias: nil,
|
|
4
|
+
style: :relative)
|
|
5
|
+
raise OptionError.new("style: must be :relative or :calendar", option: :style, value: style) unless Describe::STYLES.include?(style)
|
|
6
|
+
zone ||= value.zone if Result === value
|
|
7
|
+
ctx = Context.new(now, zone, context, locale, week_start, hemisphere, fiscal_year_start, strict, dst, holidays,
|
|
8
|
+
business_days, year_bias)
|
|
9
|
+
return Describe.duration(value) if Duration === value
|
|
10
|
+
return value.to_s if Rule === value
|
|
11
|
+
# strict: is left out: what describe writes always reads back strictly
|
|
12
|
+
opts = { now: Time.at(ctx.now), zone: zone || ctx.zone.name, context: context, locale: locale,
|
|
13
|
+
week_start: week_start, hemisphere: hemisphere, fiscal_year_start: fiscal_year_start, dst: dst,
|
|
14
|
+
holidays: holidays, business_days: business_days, year_bias: year_bias }
|
|
15
|
+
Describe.new(value, style == :calendar, ctx, opts).text
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# English that Acute.parse reads back as the value given (D45). Candidates come most natural first; each is
|
|
19
|
+
# parsed with the same now, zone and options, and the first that gives the value as its only reading wins.
|
|
20
|
+
# The last candidates write the date, the year, the zone and finally ISO 8601, which always read back.
|
|
21
|
+
# @api private
|
|
22
|
+
class Describe
|
|
23
|
+
STYLES = %i[relative calendar].freeze
|
|
24
|
+
MONTHS = Result::MONTHS
|
|
25
|
+
WEEKDAYS = Result::WEEKDAYS
|
|
26
|
+
MONTH_NAMES = Ractor.make_shareable(%w[January February March April May June July August September October
|
|
27
|
+
November December])
|
|
28
|
+
DAY_NAMES = Ractor.make_shareable(%w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday])
|
|
29
|
+
PARTS = Ractor.make_shareable(%w[morning afternoon evening night])
|
|
30
|
+
SEASONS = Ractor.make_shareable(%w[spring summer autumn winter])
|
|
31
|
+
DAY = 86_400
|
|
32
|
+
|
|
33
|
+
# Hours, minutes and seconds carried upward ("5400s" is "1h 30m"); never into days, which DST can stretch (D23)
|
|
34
|
+
def self.duration(d)
|
|
35
|
+
p = d.parts
|
|
36
|
+
return d.to_s unless p.keys.intersect?(%i[hours minutes seconds])
|
|
37
|
+
total = p.fetch(:hours, 0) * 3600 + p.fetch(:minutes, 0) * 60 + p.fetch(:seconds, 0)
|
|
38
|
+
h, rest = total.divmod(3600)
|
|
39
|
+
m, s = rest.divmod(60)
|
|
40
|
+
Duration.new(**p.except(:hours, :minutes, :seconds), hours: h, minutes: m, seconds: s).to_s
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# "9am", "9:30am", "9:00:30am", "12am" for midnight
|
|
44
|
+
def self.clock(secs, meridiem = true)
|
|
45
|
+
h = secs / 3600
|
|
46
|
+
m = secs / 60 % 60
|
|
47
|
+
s = secs % 60
|
|
48
|
+
out = (h % 12 == 0 ? 12 : h % 12).to_s
|
|
49
|
+
out << format(":%02d", m) if m != 0 || s != 0
|
|
50
|
+
out << format(":%02d", s) if s != 0
|
|
51
|
+
out << (h < 12 ? "am" : "pm") if meridiem
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A value as the span it stands for: an instant, or begin and end with either open
|
|
56
|
+
Target = Struct.new(:b, :e, :point, :instant) do
|
|
57
|
+
def self.for(value, ctx)
|
|
58
|
+
case value
|
|
59
|
+
when Result
|
|
60
|
+
new(value.begin&.to_i, value.end&.to_i, value.time.to_i, value.instant?)
|
|
61
|
+
when Range then range(value, ctx)
|
|
62
|
+
when Date then range(value..value, ctx)
|
|
63
|
+
when DateTime, Time then new(nil, nil, value.to_time.to_i, true)
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "expected a Result, Time, Date, Range or Duration, got #{value.class}" unless value.respond_to?(:time_zone)
|
|
66
|
+
new(nil, nil, value.to_time.to_i, true)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The end is exclusive, as in a Result; a Date end includes its day unless the range excludes it
|
|
71
|
+
def self.range(r, ctx)
|
|
72
|
+
b = edge(r.begin, false, ctx)
|
|
73
|
+
e = edge(r.end, !r.exclude_end?, ctx)
|
|
74
|
+
raise ArgumentError, "a range needs at least one end" unless b || e
|
|
75
|
+
raise ArgumentError, "the range runs backwards or is empty (a day the zone skipped is empty)" if b && e && e <= b
|
|
76
|
+
new(b, e, nil, false)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.edge(x, through, ctx)
|
|
80
|
+
case x
|
|
81
|
+
when nil then nil
|
|
82
|
+
when DateTime, Time then x.to_time.to_i
|
|
83
|
+
when Date then ctx.zone.local_to_utc((x.jd - 2_440_588 + (through ? 1 : 0)) * DAY, ctx)
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "a range's ends must be Times or Dates, got #{x.class}" unless x.respond_to?(:time_zone)
|
|
86
|
+
x.to_time.to_i
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def initialize(value, calendar, ctx, opts)
|
|
92
|
+
@calendar = calendar
|
|
93
|
+
@ctx = ctx
|
|
94
|
+
@zone = ctx.zone
|
|
95
|
+
@opts = opts
|
|
96
|
+
@today = ctx.today
|
|
97
|
+
@day_first = ctx.locale.day_first
|
|
98
|
+
t = Target.for(value, ctx)
|
|
99
|
+
@b, @e, @point, @instant = t.b, t.e, t.point, t.instant
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def text
|
|
103
|
+
found = to_enum(:candidates).find { fits?(_1) }
|
|
104
|
+
return found if found
|
|
105
|
+
return @last unless @point && !@instant
|
|
106
|
+
# No part of the day says this span's point, so the span alone is written
|
|
107
|
+
@point = nil
|
|
108
|
+
to_enum(:candidates).find { fits?(_1) } || @last
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def fits?(text)
|
|
114
|
+
@last = text
|
|
115
|
+
r = Acute.parse(text, **@opts)
|
|
116
|
+
return false unless r && !r.ambiguous?
|
|
117
|
+
return !r.span? && r.time.to_i == @point if @instant
|
|
118
|
+
r.begin&.to_i == @b && r.end&.to_i == @e && (@point.nil? || r.time.to_i == @point)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def candidates(&)
|
|
122
|
+
return instant(@point, &) if @instant
|
|
123
|
+
return before(&) unless @b
|
|
124
|
+
return after(&) unless @e
|
|
125
|
+
part_of_day(&) if @point && @point != @b
|
|
126
|
+
aligned?(@b) && aligned?(@e) ? days(&) : span(&)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def instant(p)
|
|
130
|
+
unless @calendar
|
|
131
|
+
yield "now" if p == @ctx.now
|
|
132
|
+
w = day_word(day_of(p))
|
|
133
|
+
yield "#{w} at #{clock(secs_of(p))}" if w
|
|
134
|
+
end
|
|
135
|
+
dated(p) { yield _1 }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# "Sun, Oct 18, 9am", then with the year, its zone's abbreviation, its offset, and ISO 8601
|
|
139
|
+
def dated(p)
|
|
140
|
+
d = day_of(p)
|
|
141
|
+
c = clock(secs_of(p))
|
|
142
|
+
yield "#{date(d, false)}, #{c}"
|
|
143
|
+
at = "#{date(d, true)}, #{c}"
|
|
144
|
+
yield at
|
|
145
|
+
yield "#{at} #{@zone.abbreviation_at(p)}"
|
|
146
|
+
yield "#{at} #{offset(p)}"
|
|
147
|
+
yield iso(p)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def days_named(d)
|
|
151
|
+
if !@calendar && (w = day_word(d))
|
|
152
|
+
yield w
|
|
153
|
+
end
|
|
154
|
+
yield date(d, false)
|
|
155
|
+
yield date(d, true)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def after
|
|
159
|
+
if aligned?(@b)
|
|
160
|
+
d = day_of(@b)
|
|
161
|
+
days_named(d) { yield "#{_1} onwards" }
|
|
162
|
+
days_named(d - 1) { yield "after #{_1}" }
|
|
163
|
+
end
|
|
164
|
+
yield "after #{clock(secs_of(@b))}" unless @calendar
|
|
165
|
+
dated(@b) do |x|
|
|
166
|
+
yield "after #{x}"
|
|
167
|
+
yield "#{x} onwards"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def before
|
|
172
|
+
days_named(day_of(@e)) { yield "before #{_1}" } if aligned?(@e)
|
|
173
|
+
yield "before #{clock(secs_of(@e))}" unless @calendar
|
|
174
|
+
dated(@e) { yield "before #{_1}" }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# "tonight", "tomorrow morning", "Sun, Oct 18, morning": a part of the day with its point
|
|
178
|
+
def part_of_day
|
|
179
|
+
d = day_of(@point)
|
|
180
|
+
unless @calendar
|
|
181
|
+
if d == @today
|
|
182
|
+
yield "tonight"
|
|
183
|
+
PARTS.each { yield "this #{_1}" }
|
|
184
|
+
end
|
|
185
|
+
yield "last night" if d == @today - 1
|
|
186
|
+
if (w = day_word(d))
|
|
187
|
+
PARTS.each { yield "#{w} #{_1}" }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
[false, true].each { |year| PARTS.each { |part| yield "#{date(d, year)}, #{part}" } }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Whole days: a day, a named period, or the first and last days
|
|
194
|
+
def days(&)
|
|
195
|
+
d1 = day_of(@b)
|
|
196
|
+
d2 = day_of(@e)
|
|
197
|
+
return days_named(d1, &) if d2 - d1 == 1
|
|
198
|
+
y1, m1, dd1 = ymd(d1)
|
|
199
|
+
y2, m2, dd2 = ymd(d2)
|
|
200
|
+
months = (y2 - y1) * 12 + m2 - m1
|
|
201
|
+
if d2 - d1 == 7 && Calendar.weekday(d1) == @ctx.week_start
|
|
202
|
+
relative("week", &)
|
|
203
|
+
yield "the week of #{md(d1, false)}"
|
|
204
|
+
yield "the week of #{md(d1, true)}"
|
|
205
|
+
elsif dd1 == 1 && dd2 == 1
|
|
206
|
+
if months == 1
|
|
207
|
+
relative("month", &)
|
|
208
|
+
yield MONTH_NAMES[m1 - 1]
|
|
209
|
+
yield "#{MONTH_NAMES[m1 - 1]} #{y1}"
|
|
210
|
+
elsif months == 3
|
|
211
|
+
if m1 % 3 == 1
|
|
212
|
+
relative("quarter", &)
|
|
213
|
+
yield "Q#{m1 / 3 + 1}"
|
|
214
|
+
yield "Q#{m1 / 3 + 1} #{y1}"
|
|
215
|
+
end
|
|
216
|
+
SEASONS.each { yield "#{_1} #{y1}" }
|
|
217
|
+
elsif m1 == 1 && m2 == 1
|
|
218
|
+
if months == 12
|
|
219
|
+
relative("year", &)
|
|
220
|
+
yield y1.to_s
|
|
221
|
+
end
|
|
222
|
+
yield "the #{y1}s" if months == 120 && y1 % 10 == 0
|
|
223
|
+
yield "#{y1} – #{y2 - 1}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
yield "#{md(d1, false)} – #{md(d2 - 1, false)}"
|
|
227
|
+
yield "#{md(d1, true)} – #{md(d2 - 1, true)}"
|
|
228
|
+
yield "#{iso(@b)} – #{iso(@e)}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def relative(unit)
|
|
232
|
+
return if @calendar
|
|
233
|
+
yield "this #{unit}"
|
|
234
|
+
yield "next #{unit}"
|
|
235
|
+
yield "last #{unit}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A span with clock times: "today, 2–5pm", "since Oct 10, 2pm", "Oct 10, 2pm – Oct 17, 2pm"
|
|
239
|
+
def span
|
|
240
|
+
d1 = day_of(@b)
|
|
241
|
+
s1 = secs_of(@b)
|
|
242
|
+
s2 = secs_of(@e)
|
|
243
|
+
d2 = day_of(@e)
|
|
244
|
+
c1 = clock(s1)
|
|
245
|
+
c2 = clock(s2)
|
|
246
|
+
if !@calendar && @e == @ctx.now
|
|
247
|
+
days_named(d1) { yield "since #{_1}" } if aligned?(@b)
|
|
248
|
+
yield "since #{md(d1, false)}, #{c1}"
|
|
249
|
+
dated(@b) { yield "since #{_1}" }
|
|
250
|
+
end
|
|
251
|
+
if d2 == d1 || (d2 == d1 + 1 && aligned?(@e))
|
|
252
|
+
short = s2 != 0 && (s1 < 43_200) == (s2 < 43_200) ? "#{clock(s1, false)}–#{c2}" : "#{c1}–#{c2}"
|
|
253
|
+
days_named(d1) do |x|
|
|
254
|
+
yield "#{x}, #{short}"
|
|
255
|
+
yield "#{x}, #{c1} – #{c2}"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
if !@calendar && (w1 = day_word(d1)) && (w2 = day_word(d2))
|
|
259
|
+
yield "#{w1}, #{c1} – #{w2}, #{c2}"
|
|
260
|
+
end
|
|
261
|
+
yield "#{md(d1, false)}, #{c1} – #{md(d2, false)}, #{c2}"
|
|
262
|
+
yield "#{md(d1, true)}, #{c1} – #{md(d2, true)}, #{c2}"
|
|
263
|
+
yield "#{md(d1, true)}, #{c1} #{@zone.abbreviation_at(@b)} – #{md(d2, true)}, #{c2} #{@zone.abbreviation_at(@e)}"
|
|
264
|
+
yield "#{iso(@b)} – #{iso(@e)}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# "today", "tomorrow", "yesterday", "Friday" up to six days ahead, "last Friday" up to six back
|
|
268
|
+
def day_word(d)
|
|
269
|
+
case d - @today
|
|
270
|
+
when 0 then "today"
|
|
271
|
+
when 1 then "tomorrow"
|
|
272
|
+
when -1 then "yesterday"
|
|
273
|
+
when 2..6 then DAY_NAMES[Calendar.weekday(d)]
|
|
274
|
+
when -6..-2 then "last #{DAY_NAMES[Calendar.weekday(d)]}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def ymd(d)
|
|
279
|
+
x = Calendar.civil_from_days(d)
|
|
280
|
+
[x / 10_000, x / 100 % 100, x % 100]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# "Oct 18" or "18 Oct", with the year: "Oct 18, 2026", "18 Oct 2026"
|
|
284
|
+
def md(d, year)
|
|
285
|
+
y, m, dd = ymd(d)
|
|
286
|
+
@day_first ? "#{dd} #{MONTHS[m - 1]}#{" #{y}" if year}" : "#{MONTHS[m - 1]} #{dd}#{", #{y}" if year}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# "Sun, Oct 18" or "Sun 18 Oct"
|
|
290
|
+
def date(d, year)
|
|
291
|
+
wd = WEEKDAYS[Calendar.weekday(d)]
|
|
292
|
+
@day_first ? "#{wd} #{md(d, year)}" : "#{wd}, #{md(d, year)}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def clock(secs, meridiem = true) = Describe.clock(secs, meridiem)
|
|
296
|
+
|
|
297
|
+
def local(epoch) = epoch + @zone.offset_at(epoch)
|
|
298
|
+
|
|
299
|
+
def day_of(epoch) = local(epoch).div(DAY)
|
|
300
|
+
|
|
301
|
+
def secs_of(epoch) = local(epoch) % DAY
|
|
302
|
+
|
|
303
|
+
def day_start(d) = @zone.local_to_utc(d * DAY, @ctx)
|
|
304
|
+
|
|
305
|
+
def aligned?(epoch) = day_start(day_of(epoch)) == epoch
|
|
306
|
+
|
|
307
|
+
def offset(epoch)
|
|
308
|
+
o = @zone.offset_at(epoch)
|
|
309
|
+
format("%s%02d:%02d", o < 0 ? "-" : "+", o.abs / 3600, o.abs / 60 % 60)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def iso(epoch) = Time.at(epoch, in: @zone.offset_at(epoch)).strftime("%FT%T%:z")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private_constant :Describe
|
|
316
|
+
end
|