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,165 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# Ranges ("monday to friday", "from 3 to 5pm") and open ends ("after friday", "since monday")
|
|
4
|
+
module Ranges
|
|
5
|
+
DAY = Period::DAY
|
|
6
|
+
UNDER_WAY = Assumption.for(:range_under_way)
|
|
7
|
+
|
|
8
|
+
def self.spans(node, ctx)
|
|
9
|
+
from = node.from
|
|
10
|
+
to = node.to
|
|
11
|
+
return clocks(meridiem(from, to), to, ctx) if G::Clock === from && G::Clock === to
|
|
12
|
+
return numeric(node, ctx) if G::Date === from && G::Date === to && from.sep && from.sep == to.sep
|
|
13
|
+
f = Resolve.spans(from, ctx)[0]
|
|
14
|
+
return [] unless f
|
|
15
|
+
# A fixed end gives a range per reading: "through midnight today" to today's end, then its start (D6)
|
|
16
|
+
ends = G::Clock === to || Periodic.cycle(to) ? [after(to, ctx, f.b)] : Resolve.spans(to, ctx)
|
|
17
|
+
list = ends.filter_map { _1 && finish(f, _1) }
|
|
18
|
+
under_way(from, to, f, list, ctx) if !ctx.past? && Periodic.cycle(from) == :year
|
|
19
|
+
list
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# D44: a range of dates or months under way ("may to september" in june) is the current one, then the
|
|
23
|
+
# coming one
|
|
24
|
+
def self.under_way(from, to, f, list, ctx)
|
|
25
|
+
now = ctx.now_local
|
|
26
|
+
if f.b > now
|
|
27
|
+
b = Periodic.last(from, ctx, Period.index(:year, ctx, now), :year) { |s| s.b <= now }[0]
|
|
28
|
+
cur = b && (t = after(to, ctx, b.b)) && finish(b, t)
|
|
29
|
+
return unless cur && cur.e > now
|
|
30
|
+
list.unshift(cur)
|
|
31
|
+
else
|
|
32
|
+
# one that starts today is today's alone, as a date said on its day is
|
|
33
|
+
return unless list.size == 1 && list[0].e > now && f.b < ctx.today * DAY
|
|
34
|
+
n = Periodic.first(from, ctx, Period.index(:year, ctx, f.b) + 1, :year) { true }[0]
|
|
35
|
+
coming = n && (t = after(to, ctx, n.b)) && finish(n, t)
|
|
36
|
+
return unless coming
|
|
37
|
+
list << coming
|
|
38
|
+
end
|
|
39
|
+
ctx.note(UNDER_WAY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# D10: both ends of "5/1-5/7" are read in the same order, the locale's first
|
|
43
|
+
def self.numeric(node, ctx)
|
|
44
|
+
out = []
|
|
45
|
+
[false, true].each do |swap|
|
|
46
|
+
f = Dates.order(node.from, ctx, swap)
|
|
47
|
+
t = Dates.order(node.to, ctx, swap)
|
|
48
|
+
next unless f && t
|
|
49
|
+
spans(G::Range.new(f, t), ctx).each { |x| out << x unless out.any? { |o| o.b == x.b && o.e == x.e } }
|
|
50
|
+
end
|
|
51
|
+
ctx.note(Dates::ORDER) if out.size > 1
|
|
52
|
+
out
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.inside(node, outer, s, ctx)
|
|
56
|
+
from = G::Clock === node.from && G::Clock === node.to ? meridiem(node.from, node.to) : node.from
|
|
57
|
+
f = Periodic.inside(from, outer, s, ctx)[0]
|
|
58
|
+
return [] unless f
|
|
59
|
+
t = G::Clock === node.to ? after(node.to, ctx, f.b) : Periodic.inside(node.to, outer, s, ctx).find { |x| x.b >= f.b }
|
|
60
|
+
# D48: a weekday earlier in the week than the start is the one after it, out of the week
|
|
61
|
+
t ||= after(node.to, ctx, f.b) if G::Weekday === node.from && G::Weekday === node.to
|
|
62
|
+
(x = t && finish(f, t)) ? [x] : []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# "2 to 4pm" is 2pm; "11 to 2pm" is 11am
|
|
66
|
+
def self.meridiem(from, to)
|
|
67
|
+
return from if from.meridiem || from.named || !to.meridiem || from.hour > 12
|
|
68
|
+
same = from.hour % 12 < to.hour % 12 || (from.hour % 12 == to.hour % 12 && from.min < to.min)
|
|
69
|
+
G::Clock.new(from.hour, from.min, from.sec, same ? to.meridiem : (to.meridiem == :pm ? :am : :pm), nil,
|
|
70
|
+
false, from.seconds)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# D40: a clock range under way is today's, one answer, or yesterday's when it runs past midnight (D44);
|
|
74
|
+
# otherwise the next one (the previous in past context)
|
|
75
|
+
def self.clocks(from, to, ctx)
|
|
76
|
+
now = ctx.now_local
|
|
77
|
+
f = Periodic.inside(from, nil, Period.span(:day, ctx, ctx.today), ctx)[0]
|
|
78
|
+
span = Span.new(f.b, clock_after(to, ctx, f.b), f.grain, nil)
|
|
79
|
+
if span.b > now && span.e - DAY > now
|
|
80
|
+
span = Span.new(span.b - DAY, span.e - DAY, span.grain, nil)
|
|
81
|
+
elsif ctx.past? ? span.b > now : span.e <= now
|
|
82
|
+
k = ctx.past? ? -DAY : DAY
|
|
83
|
+
span = Span.new(span.b + k, span.e + k, span.grain, nil)
|
|
84
|
+
end
|
|
85
|
+
[span]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# D9: days and larger include the end; with a clock the end is the stated time
|
|
89
|
+
# An end's exact UTC is kept, so "1am EDT – 1:30am EST" stays on each side of the repeated hour, and
|
|
90
|
+
# "1:50am EDT – 1:10am EST" runs forwards
|
|
91
|
+
def self.finish(f, t)
|
|
92
|
+
clock = clock?(t)
|
|
93
|
+
e = clock ? t.b : t.e
|
|
94
|
+
ue = clock ? t.ub : t.ue
|
|
95
|
+
forwards = f.ub && ue ? ue > f.ub : e > f.b
|
|
96
|
+
forwards ? Span.new(f.b, e, f.grain, nil, f.ub, ue) : nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.clock?(s) = s.grain == :minute || s.grain == :second
|
|
100
|
+
|
|
101
|
+
# The first reading of node from t on
|
|
102
|
+
def self.after(node, ctx, t)
|
|
103
|
+
if G::Clock === node
|
|
104
|
+
grain, len = Clock.grain(node)
|
|
105
|
+
return Span.new(x = clock_after(node, ctx, t), x + len, grain, nil)
|
|
106
|
+
end
|
|
107
|
+
if (cyc = Periodic.cycle(node))
|
|
108
|
+
return Periodic.first(node, ctx, Period.index(cyc, ctx, t) - 1, cyc) { |s| s.b >= t }[0]
|
|
109
|
+
end
|
|
110
|
+
Resolve.spans(node, ctx)[0]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The earliest reading of a clock after t, over every am/pm reading
|
|
114
|
+
def self.clock_after(c, ctx, t)
|
|
115
|
+
d = Period.day(t) * DAY
|
|
116
|
+
best = nil
|
|
117
|
+
Clock.candidates(c, ctx, true).each do |secs|
|
|
118
|
+
[d + secs, d + DAY + secs].each { |x| best = x if x > t && (best.nil? || x < best) }
|
|
119
|
+
end
|
|
120
|
+
best
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# D16, D34: since closes at now and looks back; until opens at now; before ends when X starts; after
|
|
124
|
+
# starts when X ends, or at a clock time itself
|
|
125
|
+
def self.open(node, ctx)
|
|
126
|
+
now = ctx.now_local
|
|
127
|
+
past = ctx.past
|
|
128
|
+
ctx.past = node.dir == :since && :back
|
|
129
|
+
list = Resolve.spans(node.node, ctx)
|
|
130
|
+
ctx.past = past
|
|
131
|
+
list = current(node.node, ctx) || list if node.dir == :after && G::Holiday === node.node
|
|
132
|
+
list.map do |s|
|
|
133
|
+
case node.dir
|
|
134
|
+
when :since then Span.new(s.b, now, s.grain, nil, s.ub, ctx.now)
|
|
135
|
+
when :until
|
|
136
|
+
# D44: until the day's last second ("by EOD") is until the day's end
|
|
137
|
+
if clock?(s) && !(s.grain == :second && s.e % DAY == 0) then Span.new(now, s.b, s.grain, nil, ctx.now, s.ub)
|
|
138
|
+
else Span.new(now, s.e, s.grain, nil, ctx.now, s.ue)
|
|
139
|
+
end
|
|
140
|
+
when :upto then Span.new(now, s.b, s.grain, nil, ctx.now, s.ub) # D9: "up to <date>" leaves it out
|
|
141
|
+
when :before then Span.new(nil, s.b, s.grain, nil, nil, s.ub)
|
|
142
|
+
# D44: "on or before X", "X or earlier": up to X's end, or to a clock time itself
|
|
143
|
+
when :through then clock?(s) ? Span.new(nil, s.b, s.grain, nil, nil, s.ub) : Span.new(nil, s.e, s.grain, nil, nil, s.ue)
|
|
144
|
+
when :from then Span.new(s.b, nil, s.grain, nil, s.ub)
|
|
145
|
+
else clock?(s) ? Span.new(s.b, nil, s.grain, nil, s.ub) : Span.new(s.e, nil, s.grain, nil, s.ue)
|
|
146
|
+
end
|
|
147
|
+
end.reject { |s| s.b && s.e && s.e < s.b } # D44: "until 2014" said in 2026 runs backwards, so it is no time
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# D44: "3 days starting next monday", "the week beginning february 4": that many units from its start
|
|
151
|
+
def self.lasting(node, ctx)
|
|
152
|
+
n, u = Period.whole(node.unit.count, node.unit.unit)
|
|
153
|
+
Resolve.spans(node.start, ctx).map { |s| Span.new(s.b, Period.add(s.b, n, u, ctx), s.grain, nil) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# D40: "after christmas" said on Dec 27 is this year's, whose after still holds now. Only holidays: "after
|
|
157
|
+
# january" said in October is next year's (D34)
|
|
158
|
+
def self.current(x, ctx)
|
|
159
|
+
cyc = Periodic.cycle(x)
|
|
160
|
+
list = Periodic.at(x, ctx, Period.index(cyc, ctx, ctx.now_local), cyc)
|
|
161
|
+
list if list.any? && list[0].e <= ctx.now_local
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# A Recur to a Rule. DTSTART walks the days from now (R2) rather than a one-off cycle: BYSETPOS, -2FR and
|
|
4
|
+
# lists of days have no one-off node to walk with.
|
|
5
|
+
module Recur
|
|
6
|
+
DAY = 86_400
|
|
7
|
+
FREQ = { second: [:secondly, 1], minute: [:minutely, 1], hour: [:hourly, 1], day: [:daily, 1], week: [:weekly, 1],
|
|
8
|
+
fortnight: [:weekly, 2], month: [:monthly, 1], quarter: [:monthly, 3], year: [:yearly, 1] }.freeze
|
|
9
|
+
FINE = { secondly: true, minutely: true, hourly: true }.freeze
|
|
10
|
+
STEP = { secondly: 1, minutely: 60, hourly: 3600 }.freeze
|
|
11
|
+
FINE_GRAIN = { second: true, minute: true, hour: true }.freeze
|
|
12
|
+
SEARCH = 8 * 366 + 1 # R9: the next 29 February can be 8 years away
|
|
13
|
+
EVERY_DAY = [0, 1, 2, 3, 4, 5, 6].freeze
|
|
14
|
+
MAX_EXDATES = 24
|
|
15
|
+
|
|
16
|
+
# A Rule, or a Failure's code
|
|
17
|
+
# text: the input, which the Rule carries and nothing here reads
|
|
18
|
+
def self.call(r, ctx, text)
|
|
19
|
+
freq, mult = FREQ[r.unit] if r.unit
|
|
20
|
+
return :conflict if r.unit && !freq
|
|
21
|
+
interval = (r.interval || 1) * (mult || 1)
|
|
22
|
+
return :invalid_date if interval == 0 # R15
|
|
23
|
+
if r.window # R27: the steps inside the window are the rule's clocks, on each day it runs
|
|
24
|
+
return :conflict unless FINE[freq] && r.clocks.empty? && !r.part
|
|
25
|
+
steps = window(r.window, interval * STEP[freq], ctx)
|
|
26
|
+
return steps if Symbol === steps
|
|
27
|
+
freq = nil
|
|
28
|
+
interval = 1
|
|
29
|
+
end
|
|
30
|
+
days = r.days.uniq
|
|
31
|
+
days = EVERY_DAY if days.empty? && r.except_days.any? && (freq.nil? || freq == :daily)
|
|
32
|
+
days -= r.except_days # R12
|
|
33
|
+
return :invalid_date if days.empty? && r.days.any? || r.except_days.any? && days.empty?
|
|
34
|
+
month = r.month
|
|
35
|
+
monthdays = r.monthdays.uniq
|
|
36
|
+
if r.date
|
|
37
|
+
d = r.date.sep ? Dates.orders(r.date, ctx)[0] : r.date
|
|
38
|
+
return :invalid_date unless d
|
|
39
|
+
month = d.month
|
|
40
|
+
monthdays = [d.day]
|
|
41
|
+
end
|
|
42
|
+
if month
|
|
43
|
+
return :conflict if monthdays.empty?
|
|
44
|
+
freq ||= :yearly # R9
|
|
45
|
+
return :conflict unless freq == :yearly
|
|
46
|
+
elsif r.nths.any? || r.setpos || monthdays.any?
|
|
47
|
+
freq ||= :monthly # R8, R9
|
|
48
|
+
return :conflict unless freq == :monthly
|
|
49
|
+
return :invalid_date if r.nths.any? { |n, _| n == 0 || n.abs > 5 }
|
|
50
|
+
days = MON_FRI if r.setpos
|
|
51
|
+
elsif days.any?
|
|
52
|
+
freq = :weekly if freq.nil? || freq == :daily # R6
|
|
53
|
+
return :conflict unless freq == :weekly
|
|
54
|
+
elsif r.clocks.any? || r.part || steps
|
|
55
|
+
freq ||= :daily
|
|
56
|
+
end
|
|
57
|
+
return :conflict unless freq
|
|
58
|
+
return :conflict if r.part && r.clocks.empty? # R15: "every morning"
|
|
59
|
+
return :conflict if [r.until, r.within, r.length].compact.size > 1 || (r.count && (r.until || r.within || r.length))
|
|
60
|
+
|
|
61
|
+
secs = steps || r.clocks.map { clock(_1, r.part, ctx) }.uniq.sort
|
|
62
|
+
return :conflict unless grid?(secs) # R4
|
|
63
|
+
timed = !secs.empty? || FINE[freq]
|
|
64
|
+
|
|
65
|
+
from = ctx.now
|
|
66
|
+
if r.start || r.within # R13
|
|
67
|
+
x = first(r.start || r.within, ctx)
|
|
68
|
+
return x if Symbol === x
|
|
69
|
+
from = x.begin if x.begin && (r.start || x.begin > from)
|
|
70
|
+
end
|
|
71
|
+
rule = Rule::Parts.new(freq, interval, month, monthdays, r.nths.any? ? [] : days, r.nths, r.setpos, secs)
|
|
72
|
+
start = dtstart(rule, from, ctx)
|
|
73
|
+
return :invalid_date unless start
|
|
74
|
+
|
|
75
|
+
last = r.until || r.within
|
|
76
|
+
if last
|
|
77
|
+
x = first(last, ctx)
|
|
78
|
+
return x if Symbol === x
|
|
79
|
+
until_ = ending(x, timed, ctx)
|
|
80
|
+
return until_ if Symbol === until_
|
|
81
|
+
return :no_occurrence if (timed ? ctx.local(until_) : until_) < start # R9
|
|
82
|
+
elsif r.length
|
|
83
|
+
until_ = length(r.length, start, timed, ctx)
|
|
84
|
+
end
|
|
85
|
+
bare = nil
|
|
86
|
+
exdates = r.except.map do |node|
|
|
87
|
+
x = first(node, ctx)
|
|
88
|
+
return x if Symbol === x
|
|
89
|
+
next ctx.local(x.begin) if FINE[freq] && FINE_GRAIN[x.grain] # R12: that instant, an occurrence or not
|
|
90
|
+
day = ctx.local(x.begin).div(DAY)
|
|
91
|
+
next day unless timed
|
|
92
|
+
next secs.map { day * DAY + _1 } unless FINE[freq]
|
|
93
|
+
bare ||= Rule.new(rule, ctx.zone.name, start, true, nil, nil, [], false, ctx.dst_policy, now: ctx.now, text: text)
|
|
94
|
+
walls = day_walls(bare, day, ctx)
|
|
95
|
+
return :conflict if walls.size > MAX_EXDATES # R12: "every 15 minutes except october 18"
|
|
96
|
+
walls
|
|
97
|
+
end
|
|
98
|
+
Rule.new(rule, ctx.zone.name, start, timed, r.count, until_, exdates.flatten, r.start || r.within ? true : false,
|
|
99
|
+
ctx.dst_policy, now: ctx.now, text: text)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# R4: seconds of the day, as D4, D30 and D35 read the clock, first reading only
|
|
103
|
+
def self.clock(c, part, ctx)
|
|
104
|
+
return Clock.candidates(c, ctx, false)[0] unless part
|
|
105
|
+
day = ctx.today
|
|
106
|
+
Periodic.clock(c, G::Part.new(part), Named.part(part, day, ctx), ctx)[0].b - day * DAY
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# R4: each hour with each minute, all at one second, as BYHOUR × BYMINUTE gives them
|
|
110
|
+
def self.grid?(secs)
|
|
111
|
+
rest = secs.map { _1 % 3600 }.uniq
|
|
112
|
+
rest.map { _1 % 60 }.uniq.size <= 1 && secs.size == secs.map { _1 / 3600 }.uniq.size * rest.size
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# R27: the seconds of the day from the window's start, a step apart, before its end; within one day
|
|
116
|
+
def self.window(node, step, ctx)
|
|
117
|
+
x = first(node, ctx)
|
|
118
|
+
return x if Symbol === x
|
|
119
|
+
return :incomplete unless x.begin && x.end
|
|
120
|
+
b = ctx.local(x.begin)
|
|
121
|
+
day = b.div(DAY)
|
|
122
|
+
e = ctx.local(x.end) - day * DAY
|
|
123
|
+
b -= day * DAY
|
|
124
|
+
return :conflict if e > DAY || e <= b
|
|
125
|
+
(b...e).step(step).to_a
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.first(node, ctx)
|
|
129
|
+
readings = Resolve.call(node, ctx, failure: true)
|
|
130
|
+
return readings.code if Failure === readings
|
|
131
|
+
readings[0] || :invalid_date
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# R2, R3, R14: the first occurrence on or after from, as local wall seconds (whole days: a day number)
|
|
135
|
+
def self.dtstart(rule, from, ctx)
|
|
136
|
+
local = ctx.local(from)
|
|
137
|
+
first = local.div(DAY)
|
|
138
|
+
day = first
|
|
139
|
+
while day < first + SEARCH
|
|
140
|
+
if rule.on?(day)
|
|
141
|
+
return day unless rule.secs.any? || FINE[rule.freq]
|
|
142
|
+
return day == first ? local : day * DAY if rule.secs.empty?
|
|
143
|
+
rule.secs.each { |s| return day * DAY + s if ctx.zone.local_to_utc(day * DAY + s, ctx) >= from }
|
|
144
|
+
end
|
|
145
|
+
day += 1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# R12: the wall times of an hourly or finer rule's occurrences on a local day, however many hours DST gives
|
|
150
|
+
# it; past MAX_EXDATES, more than that. The rule's end is left out, as a daily rule's times are.
|
|
151
|
+
def self.day_walls(rule, day, ctx)
|
|
152
|
+
from = ctx.zone.local_to_utc(day * DAY, ctx) - 1
|
|
153
|
+
walls = rule.next(MAX_EXDATES + 2, after: Time.at(from)).filter_map do |t|
|
|
154
|
+
wall = t.to_i + t.utc_offset
|
|
155
|
+
wall if wall < (day + 1) * DAY
|
|
156
|
+
end
|
|
157
|
+
walls.uniq
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# R10, R14: through the end of x, inclusive; a clock is that instant
|
|
161
|
+
def self.ending(x, timed, ctx)
|
|
162
|
+
if FINE_GRAIN[x.grain]
|
|
163
|
+
return timed ? x.begin : ctx.local(x.begin).div(DAY)
|
|
164
|
+
end
|
|
165
|
+
return :incomplete unless x.end
|
|
166
|
+
day = ctx.local(x.end - 1).div(DAY)
|
|
167
|
+
timed ? ctx.zone.local_to_utc((day + 1) * DAY - 1, ctx) : day
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# R11: from DTSTART, its end left out; days or longer end on the whole day before
|
|
171
|
+
def self.length(units, start, timed, ctx)
|
|
172
|
+
t = timed ? start : start * DAY
|
|
173
|
+
if units.all? { Period::SECONDS[_1.unit] }
|
|
174
|
+
return ctx.zone.local_to_utc(t, ctx) + units.sum { Period::SECONDS[_1.unit] * _1.count } - 1
|
|
175
|
+
end
|
|
176
|
+
units.each do |u|
|
|
177
|
+
n, unit = Period.whole(u.count, u.unit)
|
|
178
|
+
t = Period.add(t, n, unit, ctx)
|
|
179
|
+
end
|
|
180
|
+
day = t.div(DAY) - 1
|
|
181
|
+
timed ? ctx.zone.local_to_utc((day + 1) * DAY - 1, ctx) : day
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
MON_FRI = G::MON_FRI
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# next / this / last / past, with a unit ("next week", "past 3 days") or a repeating phrase ("next friday")
|
|
4
|
+
module Rel
|
|
5
|
+
DAY = Period::DAY
|
|
6
|
+
NEXT_TWO = Assumption.for(:next_two_readings)
|
|
7
|
+
THIS_PASSED = Assumption.for(:this_passed)
|
|
8
|
+
ROLLING = Assumption.for(:rolling_window)
|
|
9
|
+
CENTURY = Assumption.for(:century_from_00)
|
|
10
|
+
TONIGHT = G::Part.new(:tonight).freeze
|
|
11
|
+
|
|
12
|
+
def self.spans(node, ctx)
|
|
13
|
+
x = node.node
|
|
14
|
+
word = node.word
|
|
15
|
+
return Period.fiscal(x, ctx, word == :this ? 0 : word == :next || word == :coming ? 1 : -1) if G::Fiscal === x
|
|
16
|
+
return Dates.orders(x, ctx).flat_map { |d| periodic(word, d, ctx) } if G::Date === x && x.sep # D48: "last 9/11"
|
|
17
|
+
return periodic(word, x, ctx) unless G::Unit === x || G::Clock === x
|
|
18
|
+
return unit(word, x.unit, ctx) if G::Unit === x && !x.count
|
|
19
|
+
word = :next if word == :coming
|
|
20
|
+
return clock(word, x, ctx) if G::Clock === x
|
|
21
|
+
counted(word, x.count, x.unit, ctx)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# D20, D21, D32, D40
|
|
25
|
+
def self.periodic(word, x, ctx)
|
|
26
|
+
cyc = Periodic.cycle(x)
|
|
27
|
+
return [] unless cyc
|
|
28
|
+
now = ctx.now_local
|
|
29
|
+
i = Period.index(cyc, ctx, now)
|
|
30
|
+
case word
|
|
31
|
+
when :coming then Periodic.first(x, ctx, i, cyc) { |s| s.b > now } # D40: the first one after now, alone
|
|
32
|
+
when :next
|
|
33
|
+
return Periodic.at(x, ctx, i + 1, cyc) if cyc == :day # D40: "next night" is tomorrow night
|
|
34
|
+
coming = Periodic.first(x, ctx, i, cyc) { |s| s.b > now }
|
|
35
|
+
# D40: said while one is under way, it means the one after, one answer
|
|
36
|
+
return coming if under_way?(x, ctx, i, cyc, now)
|
|
37
|
+
following = Periodic.at(x, ctx, i + 1, cyc)
|
|
38
|
+
out = merge(coming, following)
|
|
39
|
+
ctx.note(NEXT_TWO) if out.size > 1 && following.any? && coming.any? && following[0].b != coming[0].b
|
|
40
|
+
out
|
|
41
|
+
when :this
|
|
42
|
+
cur = Periodic.at(x, ctx, i, cyc)
|
|
43
|
+
return cur if cyc == :day # D1: "this morning" is today's, even once it has passed
|
|
44
|
+
prev = Periodic.at(x, ctx, i - 1, cyc)
|
|
45
|
+
return prev if prev.any? && prev[0].b <= now && prev[0].e > now # D8: the weekend under way
|
|
46
|
+
return cur if cur.empty? || cur[0].e > now || ctx.past # D44: looking back, the one passed
|
|
47
|
+
ctx.note(THIS_PASSED)
|
|
48
|
+
merge(Periodic.first(x, ctx, i + 1, cyc) { |s| s.e > now }, cur)
|
|
49
|
+
else
|
|
50
|
+
x = TONIGHT if G::Part === x && x.name == :night # D1: last night starts at 17:00
|
|
51
|
+
# D51: said at exactly 00:00, "last night" is the calendar yesterday's, as D41 reads "tomorrow"
|
|
52
|
+
return Periodic.last(x, ctx, i, cyc) { |s| s.b < now } if now % DAY == 0 && x.equal?(TONIGHT)
|
|
53
|
+
Periodic.last(x, ctx, i, cyc) { |s| s.e <= now }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.under_way?(x, ctx, i, cyc, now)
|
|
58
|
+
[i - 1, i].any? do |j|
|
|
59
|
+
s = Periodic.at(x, ctx, j, cyc)[0]
|
|
60
|
+
s && s.b <= now && s.e > now
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The main readings first, then the alternatives of each
|
|
65
|
+
def self.merge(a, b)
|
|
66
|
+
out = []
|
|
67
|
+
[a.first(1), b.first(1), a.drop(1), b.drop(1)].each do |list|
|
|
68
|
+
list.each { |s| out << s unless out.any? { |o| o.b == s.b && o.e == s.e } }
|
|
69
|
+
end
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# "next week", "this month", "last year"; D38 for hours and minutes; D31 for "past week"
|
|
74
|
+
def self.unit(word, u, ctx)
|
|
75
|
+
now = ctx.now_local
|
|
76
|
+
return counted(word == :this || word == :coming ? :next : word, 2, :week, ctx) if u == :fortnight # D40
|
|
77
|
+
return counted(:past, 1, u, ctx) if word == :past
|
|
78
|
+
k = word == :next || word == :coming ? 1 : word == :this ? 0 : -1
|
|
79
|
+
if u == :business_day
|
|
80
|
+
# D44: "this weekday" on a weekend is Monday
|
|
81
|
+
b = k == 0 ? Period.business(Period.day(now) * DAY - DAY, 1, ctx) : Period.business(Period.day(now) * DAY, k, ctx)
|
|
82
|
+
return [Span.new(b, b + DAY, :day, nil)]
|
|
83
|
+
end
|
|
84
|
+
cur = Period.unit(u, ctx, now)
|
|
85
|
+
b = Period.add(cur.b, k, u)
|
|
86
|
+
calendar = Span.new(b, Period.add(b, 1, u), cur.grain, nil)
|
|
87
|
+
if u == :century # D12, D44
|
|
88
|
+
ctx.note(CENTURY)
|
|
89
|
+
return [calendar, Span.new(Period.add(b, 1, :year), Period.add(calendar.e, 1, :year), cur.grain, nil)]
|
|
90
|
+
end
|
|
91
|
+
if u == :decade && k != 0 && word != :coming # D40: the ten years from now first; "this coming" is the next to start
|
|
92
|
+
rolling = k > 0 ? Span.new(now, Period.add(now, 1, u), u, nil) : Span.new(Period.add(now, -1, u), now, u, nil)
|
|
93
|
+
return [calendar] if rolling.b == calendar.b
|
|
94
|
+
ctx.note(ROLLING)
|
|
95
|
+
return [rolling, calendar]
|
|
96
|
+
end
|
|
97
|
+
# D42: not "next second", whose rolling reading would be this second
|
|
98
|
+
return [calendar] unless (u == :hour || u == :minute) && k != 0
|
|
99
|
+
ctx.note(ROLLING)
|
|
100
|
+
[k > 0 ? window(ctx, Period::SECONDS[u], u) : window(ctx, -Period::SECONDS[u], u), calendar]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# D7, D31: rolling from now first, then whole units: the next N, or the N ending with this one and the N
|
|
104
|
+
# before it
|
|
105
|
+
def self.counted(word, n, u, ctx)
|
|
106
|
+
n, u = Period.whole(n, u)
|
|
107
|
+
if u == :fortnight
|
|
108
|
+
n *= 2
|
|
109
|
+
u = :week
|
|
110
|
+
end
|
|
111
|
+
now = ctx.now_local
|
|
112
|
+
ctx.note(ROLLING)
|
|
113
|
+
if word == :next || word == :this
|
|
114
|
+
return [window(ctx, n * Period::SECONDS[u], u)] if Period::SECONDS[u]
|
|
115
|
+
out = [Span.new(now, Period.add(now, n, u, ctx), u, nil)]
|
|
116
|
+
b = Period.add(Period.unit(u, ctx, now).b, 1, u, ctx)
|
|
117
|
+
out << Span.new(b, Period.add(b, n, u, ctx), u, nil)
|
|
118
|
+
else
|
|
119
|
+
return [window(ctx, -n * Period::SECONDS[u], u)] if Period::SECONDS[u]
|
|
120
|
+
out = [Span.new(Period.add(now, -n, u, ctx), now, u, nil)]
|
|
121
|
+
cur = Period.unit(u, ctx, now).b
|
|
122
|
+
e = Period.add(cur, 1, u, ctx)
|
|
123
|
+
out << Span.new(Period.add(e, -n, u, ctx), e, u, nil) << Span.new(Period.add(cur, -n, u, ctx), cur, u, nil)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# From now to secs away, either way. D23: hours and smaller are absolute time, so "the last 48 hours" across
|
|
128
|
+
# a DST change is still 48 hours
|
|
129
|
+
def self.window(ctx, secs, unit)
|
|
130
|
+
utc = ctx.now + secs
|
|
131
|
+
t = ctx.local(utc)
|
|
132
|
+
secs > 0 ? Span.new(ctx.now_local, t, unit, nil, ctx.now, utc) : Span.new(t, ctx.now_local, unit, nil, utc, ctx.now)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# D18: "next 3pm" is the nearest one after now, "last 3pm" the nearest before
|
|
136
|
+
def self.clock(word, c, ctx)
|
|
137
|
+
return Clock.bare(c, ctx) if word == :next || word == :this
|
|
138
|
+
past = ctx.past
|
|
139
|
+
ctx.past = :back
|
|
140
|
+
out = Clock.bare(c, ctx)
|
|
141
|
+
ctx.past = past
|
|
142
|
+
out
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module Acute
|
|
2
|
+
module Resolve
|
|
3
|
+
# "in 3 days", "2 hours ago", "a week from tomorrow", "3 business days after friday"
|
|
4
|
+
module Shift
|
|
5
|
+
FINE = { second: 0, minute: 1, hour: 2 }.freeze
|
|
6
|
+
|
|
7
|
+
# D23: hours and smaller are absolute time; days and larger are wall-clock arithmetic that keeps the
|
|
8
|
+
# time of day. With no anchor the result is an instant.
|
|
9
|
+
def self.spans(node, ctx)
|
|
10
|
+
n, u = Period.whole(node.count, node.unit)
|
|
11
|
+
unless node.anchor
|
|
12
|
+
if (len = Period::SECONDS[u])
|
|
13
|
+
utc = ctx.now + n * len
|
|
14
|
+
t = ctx.local(utc)
|
|
15
|
+
return [Span.new(t, t + 1, :second, nil, utc, utc + 1)]
|
|
16
|
+
end
|
|
17
|
+
t = Period.add(ctx.now_local, n, u, ctx)
|
|
18
|
+
return [Span.new(t, t + 1, :second, nil)]
|
|
19
|
+
end
|
|
20
|
+
len = Period::SECONDS[u]
|
|
21
|
+
Resolve.spans(node.anchor, ctx).map do |s|
|
|
22
|
+
next calendar(s, n, u, ctx) unless len
|
|
23
|
+
# D44: from the anchor's instant, at a grain no coarser than the unit
|
|
24
|
+
ub = (s.ub || ctx.zone.local_to_utc(s.b, ctx, ctx.dst_policy)) + n * len
|
|
25
|
+
grain = s.grain
|
|
26
|
+
size = s.ue ? s.ue - s.ub : s.e - s.b
|
|
27
|
+
if !(r = FINE[grain]) || r > FINE[u]
|
|
28
|
+
grain = u
|
|
29
|
+
size = len
|
|
30
|
+
end
|
|
31
|
+
Span.new(ctx.local(ub), ctx.local(ub + size), grain, nil, ub, ub + size)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RANK = { second: 0, minute: 1, hour: 2, day: 3, business_day: 3, weekend: 4, week: 4, fortnight: 4, month: 5,
|
|
36
|
+
quarter: 6, season: 6, year: 7, decade: 8, century: 9 }.freeze
|
|
37
|
+
GRAIN = { business_day: :day, fortnight: :week }.freeze
|
|
38
|
+
|
|
39
|
+
# D44: a shift in days or longer from a longer anchor counts from its start at the unit's grain ("1 day
|
|
40
|
+
# before next month"); a day or less keeps its length ("a month from today" on 28 February is 28 March)
|
|
41
|
+
def self.calendar(s, n, u, ctx)
|
|
42
|
+
b = Period.add(s.b, n, u, ctx)
|
|
43
|
+
rank = RANK.fetch(s.grain)
|
|
44
|
+
return Span.new(b, Period.add(b, 1, u, ctx), GRAIN[u] || u, nil) if rank > RANK.fetch(u)
|
|
45
|
+
return Span.new(b, b + s.e - s.b, s.grain, s.point && s.point - s.b + b) if rank <= 3
|
|
46
|
+
Span.new(b, Period.add(s.e, n, u, ctx), s.grain, s.point && Period.add(s.point, n, u, ctx))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# D44: "3 fridays from now" is the third Friday after now, not counting today's; "2 weekends ago" the
|
|
50
|
+
# second whose end has passed. One a cycle, so the nth is n - 1 cycles on from the first. From an anchor
|
|
51
|
+
# with alternatives, each gives one ("friday after next").
|
|
52
|
+
def self.counted(node, ctx)
|
|
53
|
+
n = node.n
|
|
54
|
+
return nth_from(node.node, n, ctx.now_local + (n > 0 ? 1 : 0), ctx) unless node.anchor
|
|
55
|
+
a = Resolve.spans(node.anchor, ctx)
|
|
56
|
+
return nth_from(node.node, n, n > 0 ? a[0].e : a[0].b, ctx) if a.size <= 1 && !a.empty?
|
|
57
|
+
a.flat_map { |s| nth_from(node.node, n, n > 0 ? s.e : s.b, ctx).first(1) }.uniq
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# D48: one a cycle, so the n nearest lie within n cycles of the anchor's; nearest by start, the earlier
|
|
61
|
+
# on a tie
|
|
62
|
+
def self.closest(node, ctx)
|
|
63
|
+
cyc = Periodic.cycle(node.node)
|
|
64
|
+
a = Resolve.spans(node.anchor, ctx)
|
|
65
|
+
return [] if !cyc || a.empty?
|
|
66
|
+
t = a[0].b
|
|
67
|
+
i = Period.index(cyc, ctx, t)
|
|
68
|
+
all = ((i - node.n)..(i + node.n)).flat_map { |j| Periodic.at(node.node, ctx, j, cyc) }
|
|
69
|
+
all.sort_by { |s| [(s.b - t).abs, s.b] }[node.n - 1, 1] || []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.nth_from(x, n, t, ctx)
|
|
73
|
+
cyc = Periodic.cycle(x)
|
|
74
|
+
i = Period.index(cyc, ctx, t)
|
|
75
|
+
first = n > 0 ? Periodic.first(x, ctx, i, cyc) { |s| s.b >= t } : Periodic.last(x, ctx, i, cyc) { |s| s.e <= t }
|
|
76
|
+
return first if first.empty? || n.abs == 1
|
|
77
|
+
Periodic.at(x, ctx, Period.index(cyc, ctx, first[0].b) + n - (n <=> 0), cyc)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|