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,401 @@
|
|
|
1
|
+
require "date"
|
|
2
|
+
|
|
3
|
+
module Acute
|
|
4
|
+
class Rule
|
|
5
|
+
# @api private
|
|
6
|
+
MAX_OCCURRENCES = 1_000 # R19
|
|
7
|
+
STEPS = 100_000 # R19
|
|
8
|
+
|
|
9
|
+
# R16: the next occurrence after `after` (by default the now the rule was read at), or with n the next n as an
|
|
10
|
+
# Array: Times in the rule's zone, or Dates for a rule of whole days. nil or fewer when the rule ends; nil past a
|
|
11
|
+
# limit (R19), which next! explains.
|
|
12
|
+
def next(n = nil, after: now)
|
|
13
|
+
out = listing(n || 1, after)
|
|
14
|
+
return if Failure === out
|
|
15
|
+
n ? out : out[0]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def next!(n = nil, after: now)
|
|
19
|
+
out = raising(listing(n || 1, after))
|
|
20
|
+
n ? out : out[0]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# R16: the occurrences from `from` up to `to`, `to` left out. A Date stands for its whole day.
|
|
24
|
+
def between(from, to)
|
|
25
|
+
out = window(from, to)
|
|
26
|
+
out unless Failure === out
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def between!(from, to) = raising(window(from, to))
|
|
30
|
+
|
|
31
|
+
# R16: every occurrence in order, lazily, from DTSTART or after `after`. It ends with the rule, or when R19's
|
|
32
|
+
# steps pass without one.
|
|
33
|
+
def each(after: nil)
|
|
34
|
+
return enum_for(:each, after: after) unless block_given?
|
|
35
|
+
x = expander
|
|
36
|
+
x.walk(after.nil? ? x.first : x.after(after)) do |o|
|
|
37
|
+
yield x.out(o)
|
|
38
|
+
x.refill
|
|
39
|
+
end
|
|
40
|
+
self
|
|
41
|
+
rescue Expand::Exhausted
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def expander = Expand.new(@parts, @zone, @start, @timed, @count, @until, @exdates, @dst)
|
|
48
|
+
|
|
49
|
+
def raising(out)
|
|
50
|
+
raise Explain.error(out, "") if Failure === out
|
|
51
|
+
out
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def listing(n, after)
|
|
55
|
+
raise ArgumentError, "n must be an Integer of 0 or more, got #{n.inspect}" unless Integer === n && n >= 0
|
|
56
|
+
return Failure.new(:occurrence_limit, 0, 0) if n > MAX_OCCURRENCES
|
|
57
|
+
out = []
|
|
58
|
+
return out if n == 0
|
|
59
|
+
x = expander
|
|
60
|
+
x.walk(x.after(after)) do |o|
|
|
61
|
+
out << x.out(o)
|
|
62
|
+
break if out.size == n
|
|
63
|
+
end
|
|
64
|
+
out
|
|
65
|
+
rescue Expand::Exhausted
|
|
66
|
+
Failure.new(:step_limit, 0, 0)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def window(from, to)
|
|
70
|
+
x = expander
|
|
71
|
+
lo, hi = x.span(from, to)
|
|
72
|
+
out = []
|
|
73
|
+
x.walk(lo) do |o|
|
|
74
|
+
break unless x.before?(o, hi)
|
|
75
|
+
return Failure.new(:occurrence_limit, 0, 0) if out.size == MAX_OCCURRENCES
|
|
76
|
+
out << x.out(o)
|
|
77
|
+
end
|
|
78
|
+
out
|
|
79
|
+
rescue Expand::Exhausted
|
|
80
|
+
Failure.new(:step_limit, 0, 0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# R17: RFC 5545 expansion of a Rule, one call's worth. An occurrence is [utc, wall] when the rule is timed, else
|
|
84
|
+
# a day number; walk yields them in order.
|
|
85
|
+
class Expand
|
|
86
|
+
DAY = 86_400
|
|
87
|
+
SUB = { hourly: 3600, minutely: 60, secondly: 1 }.freeze
|
|
88
|
+
MARGIN = 2 * 3600 # more than any clock change, so a period starting the local day before is looked at
|
|
89
|
+
EPOCH_JD = 2_440_588
|
|
90
|
+
NONE = [].freeze
|
|
91
|
+
Notes = Struct.new(:dst)
|
|
92
|
+
class Exhausted < StandardError; end
|
|
93
|
+
|
|
94
|
+
def initialize(parts, zone, start, timed, count, until_, exdates, dst)
|
|
95
|
+
@zone = Zone.for(zone)
|
|
96
|
+
@freq = parts.freq
|
|
97
|
+
@interval = parts.interval
|
|
98
|
+
@start = start
|
|
99
|
+
@timed = timed
|
|
100
|
+
@count = count
|
|
101
|
+
@until = until_
|
|
102
|
+
@exdates = exdates
|
|
103
|
+
@dst = dst
|
|
104
|
+
@policy = dst == :reject ? nil : dst
|
|
105
|
+
@notes = Notes.new
|
|
106
|
+
@steps = STEPS
|
|
107
|
+
@d0 = timed ? start.div(DAY) : start
|
|
108
|
+
@month = parts.month
|
|
109
|
+
@monthdays = parts.monthdays
|
|
110
|
+
@days = parts.days
|
|
111
|
+
@nths = parts.nths
|
|
112
|
+
@setpos = parts.setpos
|
|
113
|
+
@secs = parts.secs.empty? && timed && !SUB[@freq] ? [start % DAY] : parts.secs.sort
|
|
114
|
+
derive
|
|
115
|
+
@anchor = @zone.local_to_utc(start, @notes, @policy) if timed
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def first = @timed ? @anchor : @d0
|
|
119
|
+
|
|
120
|
+
def refill = @steps = STEPS
|
|
121
|
+
|
|
122
|
+
# The lowest occurrence after x: an instant after it, or a day still under way at it (R2); a Date is its
|
|
123
|
+
# whole day
|
|
124
|
+
def after(x)
|
|
125
|
+
if plain_date?(x)
|
|
126
|
+
day = x.jd - EPOCH_JD + 1
|
|
127
|
+
return @timed ? day_start(day) : day
|
|
128
|
+
end
|
|
129
|
+
t = epoch(x).floor
|
|
130
|
+
@timed ? t + 1 : local_day(t)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# [lowest, past the highest] of the occurrences from `from` up to `to`; a day counts when any of it is
|
|
134
|
+
def span(from, to)
|
|
135
|
+
lo = if plain_date?(from)
|
|
136
|
+
day = from.jd - EPOCH_JD
|
|
137
|
+
@timed ? day_start(day) : day
|
|
138
|
+
else
|
|
139
|
+
t = epoch(from).ceil
|
|
140
|
+
@timed ? t : local_day(t)
|
|
141
|
+
end
|
|
142
|
+
hi = if plain_date?(to)
|
|
143
|
+
day = to.jd - EPOCH_JD + 1
|
|
144
|
+
@timed ? day_start(day) : day
|
|
145
|
+
else
|
|
146
|
+
t = epoch(to).ceil
|
|
147
|
+
@timed ? t : local_day(t - 1) + 1
|
|
148
|
+
end
|
|
149
|
+
[lo, hi]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def before?(o, hi) = @timed ? o[0] < hi : o < hi
|
|
153
|
+
|
|
154
|
+
def out(o) = @timed ? Time.at(o[0], in: @zone.offset_at(o[0])) : ::Date.jd(o + EPOCH_JD)
|
|
155
|
+
|
|
156
|
+
def walk(lo, &) = SUB[@freq] ? sub_walk(lo, &) : day_walk(lo, &)
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# R17: a part the rule doesn't name comes from DTSTART, as RFC 5545 says
|
|
161
|
+
def derive
|
|
162
|
+
ymd = Calendar.civil_from_days(@d0)
|
|
163
|
+
bare = @monthdays.empty? && @days.empty? && @nths.empty?
|
|
164
|
+
case @freq
|
|
165
|
+
when :weekly
|
|
166
|
+
@days = [Calendar.weekday(@d0)] if @days.empty?
|
|
167
|
+
when :monthly
|
|
168
|
+
@monthdays = [ymd % 100] if bare
|
|
169
|
+
when :yearly
|
|
170
|
+
if bare
|
|
171
|
+
@month ||= ymd / 100 % 100
|
|
172
|
+
@monthdays = [ymd % 100]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def plain_date?(x) = Date === x && !(DateTime === x)
|
|
178
|
+
|
|
179
|
+
def epoch(x)
|
|
180
|
+
return x.to_r if Time === x
|
|
181
|
+
return x.to_time.to_r if x.respond_to?(:to_time)
|
|
182
|
+
raise ArgumentError, "expected a Time or a Date, got #{x.class}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def local_day(t) = (t + @zone.offset_at(t)).div(DAY)
|
|
186
|
+
|
|
187
|
+
def day_start(day) = @zone.local_to_utc(day * DAY, @notes)
|
|
188
|
+
|
|
189
|
+
def spend(n)
|
|
190
|
+
raise Exhausted if (@steps -= n) < 0
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Daily to yearly: period k is the interval's k-th day, week (from Monday), month or year after DTSTART's
|
|
194
|
+
def day_walk(lo)
|
|
195
|
+
k = @count ? 0 : first_period(lo)
|
|
196
|
+
n = 0
|
|
197
|
+
loop do
|
|
198
|
+
list = period(k)
|
|
199
|
+
spend(list.size + 1)
|
|
200
|
+
if @timed
|
|
201
|
+
return if @until && (@first - 1) * DAY > @until
|
|
202
|
+
zoned(list).each do |utc, wall|
|
|
203
|
+
next if wall < @start
|
|
204
|
+
return nil if @until && utc > @until
|
|
205
|
+
return nil if @count && (n += 1) > @count
|
|
206
|
+
yield [utc, wall] unless utc < lo || @exdates.include?(wall)
|
|
207
|
+
end
|
|
208
|
+
else
|
|
209
|
+
return if @until && @first > @until
|
|
210
|
+
list.each do |day|
|
|
211
|
+
next if day < @start
|
|
212
|
+
return nil if @until && day > @until
|
|
213
|
+
return nil if @count && (n += 1) > @count
|
|
214
|
+
yield day unless day < lo || @exdates.include?(day)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
k += 1
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# The period holding lo's day, or the last one before it, so a jump skips what comes before lo
|
|
222
|
+
def first_period(lo)
|
|
223
|
+
day = @timed ? local_day(lo - MARGIN) : lo
|
|
224
|
+
i = case @freq
|
|
225
|
+
when :daily then day - @d0
|
|
226
|
+
when :weekly then (monday(day) - monday(@d0)) / 7
|
|
227
|
+
when :monthly then month_index(day) - month_index(@d0)
|
|
228
|
+
else year(day) - year(@d0)
|
|
229
|
+
end
|
|
230
|
+
k = i.div(@interval)
|
|
231
|
+
k < 0 ? 0 : k
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Period k's candidates in order, after BYSETPOS: wall seconds when timed, else days
|
|
235
|
+
def period(k)
|
|
236
|
+
step = k * @interval
|
|
237
|
+
days = case @freq
|
|
238
|
+
when :daily
|
|
239
|
+
@first = @d0 + step
|
|
240
|
+
day_ok?(@first) ? [@first] : NONE
|
|
241
|
+
when :weekly
|
|
242
|
+
@first = monday(@d0) + 7 * step
|
|
243
|
+
list = @days.map { @first + (_1 + 6) % 7 }.sort
|
|
244
|
+
@month ? list.select { month_of(_1) == @month } : list
|
|
245
|
+
when :monthly
|
|
246
|
+
y, m = (month_index(@d0) + step).divmod(12)
|
|
247
|
+
@first = Calendar.days_from_civil(y, m + 1, 1)
|
|
248
|
+
@month && @month != m + 1 ? NONE : in_month(@first, Calendar.days_in_month(y, m + 1))
|
|
249
|
+
else
|
|
250
|
+
y = year(@d0) + step
|
|
251
|
+
@first = Calendar.year_start(y)
|
|
252
|
+
if @month
|
|
253
|
+
in_month(Calendar.days_from_civil(y, @month, 1), Calendar.days_in_month(y, @month))
|
|
254
|
+
elsif @monthdays.empty?
|
|
255
|
+
by_weekday(@first, Calendar.year_start(y + 1)).sort.uniq # RFC 5545: an ordinal counts in the year
|
|
256
|
+
else
|
|
257
|
+
(1..12).flat_map { in_month(Calendar.days_from_civil(y, _1, 1), Calendar.days_in_month(y, _1)) }
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
list = @timed ? days.flat_map { |d| @secs.map { d * DAY + _1 } } : days
|
|
261
|
+
@setpos ? @setpos.filter_map { list[_1 > 0 ? _1 - 1 : _1] }.uniq.sort : list
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# R18: wall times to [utc, wall] by the dst: policy; :reject leaves out a skipped or repeated time
|
|
265
|
+
def zoned(walls)
|
|
266
|
+
shifted = false
|
|
267
|
+
out = []
|
|
268
|
+
walls.each do |w|
|
|
269
|
+
@notes.dst = nil
|
|
270
|
+
utc = @zone.local_to_utc(w, @notes, @policy)
|
|
271
|
+
if @notes.dst
|
|
272
|
+
next if @dst == :reject
|
|
273
|
+
shifted = true
|
|
274
|
+
end
|
|
275
|
+
out << [utc, w]
|
|
276
|
+
end
|
|
277
|
+
return out unless shifted && out.size > 1
|
|
278
|
+
# A skipped time moved forward can land on a later one, which it then is
|
|
279
|
+
out.sort_by!(&:first)
|
|
280
|
+
out.uniq!(&:first)
|
|
281
|
+
out
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# R18: hourly and finer step in elapsed time from DTSTART (D23)
|
|
285
|
+
def sub_walk(lo)
|
|
286
|
+
unit = SUB[@freq]
|
|
287
|
+
step = unit * @interval
|
|
288
|
+
a = @anchor
|
|
289
|
+
if @secs.empty? && every_day?
|
|
290
|
+
k = lo <= a ? 0 : (lo - a + step - 1).div(step) # each step is one occurrence, so k is also the count
|
|
291
|
+
loop do
|
|
292
|
+
spend(1)
|
|
293
|
+
t = a + k * step
|
|
294
|
+
return if @count && k >= @count
|
|
295
|
+
return if @until && t > @until
|
|
296
|
+
wall = t + @zone.offset_at(t)
|
|
297
|
+
yield [t, wall] unless @exdates.include?(wall)
|
|
298
|
+
k += 1
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
p0 = a - @start % unit # the start of DTSTART's hour, minute or second
|
|
302
|
+
n = 0
|
|
303
|
+
day = (@count ? @d0 : [local_day(lo - MARGIN), @d0].max) - 1
|
|
304
|
+
loop do
|
|
305
|
+
day += 1
|
|
306
|
+
spend(1)
|
|
307
|
+
s0 = day_start(day)
|
|
308
|
+
return if @until && s0 > @until
|
|
309
|
+
next unless day_ok?(day)
|
|
310
|
+
if @secs.empty?
|
|
311
|
+
s1 = day_start(day + 1)
|
|
312
|
+
t = s0 <= a ? a : a + (s0 - a + step - 1).div(step) * step
|
|
313
|
+
while t < s1
|
|
314
|
+
spend(1)
|
|
315
|
+
return if @until && t > @until
|
|
316
|
+
return if @count && (n += 1) > @count
|
|
317
|
+
wall = t + @zone.offset_at(t)
|
|
318
|
+
yield [t, wall] unless t < lo || @exdates.include?(wall)
|
|
319
|
+
t += step
|
|
320
|
+
end
|
|
321
|
+
else
|
|
322
|
+
zoned(@secs.map { day * DAY + _1 }).each do |t, wall|
|
|
323
|
+
spend(1)
|
|
324
|
+
next if t < a || !((t - p0).div(unit) % @interval).zero?
|
|
325
|
+
return nil if @until && t > @until
|
|
326
|
+
return nil if @count && (n += 1) > @count
|
|
327
|
+
yield [t, wall] unless t < lo || @exdates.include?(wall)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def every_day? = @month.nil? && @monthdays.empty? && @days.empty? && @nths.empty?
|
|
334
|
+
|
|
335
|
+
# BYMONTH, BYMONTHDAY and BYDAY as limits on one day
|
|
336
|
+
def day_ok?(d)
|
|
337
|
+
return true if every_day?
|
|
338
|
+
ymd = Calendar.civil_from_days(d)
|
|
339
|
+
m = ymd / 100 % 100
|
|
340
|
+
return false if @month && @month != m
|
|
341
|
+
dim = Calendar.days_in_month(ymd / 10_000, m)
|
|
342
|
+
unless @monthdays.empty?
|
|
343
|
+
md = ymd % 100
|
|
344
|
+
return false unless @monthdays.any? { (_1 > 0 ? _1 : dim + _1 + 1) == md }
|
|
345
|
+
end
|
|
346
|
+
first = d - ymd % 100 + 1
|
|
347
|
+
(@days.empty? && @nths.empty?) || weekday_ok?(d, first, first + dim)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# A month's days: BYMONTHDAY, limited by BYDAY, or BYDAY alone
|
|
351
|
+
def in_month(first, dim)
|
|
352
|
+
z = first + dim
|
|
353
|
+
list = if @monthdays.empty?
|
|
354
|
+
by_weekday(first, z)
|
|
355
|
+
else
|
|
356
|
+
days = @monthdays.filter_map do |md|
|
|
357
|
+
x = md > 0 ? md : dim + md + 1
|
|
358
|
+
first + x - 1 if x >= 1 && x <= dim
|
|
359
|
+
end
|
|
360
|
+
@days.empty? && @nths.empty? ? days : days.select { weekday_ok?(_1, first, z) }
|
|
361
|
+
end
|
|
362
|
+
list.size > 1 ? list.sort.uniq : list
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# The days from a up to z on a BYDAY weekday, an ordinal one counted within them
|
|
366
|
+
def by_weekday(a, z)
|
|
367
|
+
out = []
|
|
368
|
+
@days.each do |w|
|
|
369
|
+
d = a + (w - Calendar.weekday(a)) % 7
|
|
370
|
+
while d < z
|
|
371
|
+
out << d
|
|
372
|
+
d += 7
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
@nths.each do |n, w|
|
|
376
|
+
d = n > 0 ? a + (w - Calendar.weekday(a)) % 7 + 7 * (n - 1) : z - 1 - (Calendar.weekday(z - 1) - w) % 7 - 7 * (-n - 1)
|
|
377
|
+
out << d if d >= a && d < z
|
|
378
|
+
end
|
|
379
|
+
out
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def weekday_ok?(d, a, z)
|
|
383
|
+
w = Calendar.weekday(d)
|
|
384
|
+
@days.include?(w) || @nths.any? { |n, nw| nw == w && (n > 0 ? (d - a) / 7 + 1 == n : (z - 1 - d) / 7 + 1 == -n) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def monday(d) = d - (d + 3) % 7
|
|
388
|
+
|
|
389
|
+
def month_of(d) = Calendar.civil_from_days(d) / 100 % 100
|
|
390
|
+
|
|
391
|
+
def month_index(d)
|
|
392
|
+
ymd = Calendar.civil_from_days(d)
|
|
393
|
+
ymd / 10_000 * 12 + ymd / 100 % 100 - 1
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def year(d) = Calendar.civil_from_days(d) / 10_000
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
private_constant :Expand
|
|
400
|
+
end
|
|
401
|
+
end
|
data/lib/acute/rule.rb
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "date"
|
|
3
|
+
|
|
4
|
+
module Acute
|
|
5
|
+
# A repeating time as an iCalendar rule (RFC 5545): a DTSTART, an RRULE and any EXDATE (R1). Frozen and
|
|
6
|
+
# Ractor-shareable.
|
|
7
|
+
class Rule
|
|
8
|
+
DAYS = Ractor.make_shareable(%w[SU MO TU WE TH FR SA])
|
|
9
|
+
DAY_NAMES = Ractor.make_shareable(%w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday])
|
|
10
|
+
MONTH_NAMES = Ractor.make_shareable(%w[_ January February March April May June July August September October November December])
|
|
11
|
+
ORDINALS = Ractor.make_shareable(%w[_ first second third fourth fifth])
|
|
12
|
+
UNITS = Ractor.make_shareable({ secondly: "second", minutely: "minute", hourly: "hour", daily: "day", weekly: "week",
|
|
13
|
+
monthly: "month", yearly: "year" })
|
|
14
|
+
DAY = 86_400
|
|
15
|
+
|
|
16
|
+
# What repeats: secs are seconds of the day, sorted, each hour said with each minute said (R4)
|
|
17
|
+
# @api private
|
|
18
|
+
Parts = Struct.new(:freq, :interval, :month, :monthdays, :days, :nths, :setpos, :secs) do
|
|
19
|
+
def on?(day)
|
|
20
|
+
ymd = Calendar.civil_from_days(day)
|
|
21
|
+
m = ymd / 100 % 100
|
|
22
|
+
return false if month && month != m
|
|
23
|
+
d = ymd % 100
|
|
24
|
+
wday = Calendar.weekday(day)
|
|
25
|
+
dim = Calendar.days_in_month(ymd / 10_000, m) unless monthdays.empty? && nths.empty? && !setpos
|
|
26
|
+
return false unless monthdays.empty? || monthdays.any? { |md| md > 0 ? md == d : dim + md + 1 == d }
|
|
27
|
+
return false unless nths.empty? || nths.any? { |n, w| w == wday && (n > 0 ? (d - 1) / 7 + 1 == n : (dim - d) / 7 + 1 == -n) }
|
|
28
|
+
return false unless days.empty? || days.include?(wday)
|
|
29
|
+
return true unless setpos
|
|
30
|
+
picked = (1..dim).select { |x| days.include?(Calendar.weekday(day - d + x)) }
|
|
31
|
+
setpos.any? { |n| picked[n > 0 ? n - 1 : n] == d }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# text: the input it was read from; zone: the zone its wall times are in
|
|
36
|
+
attr_reader :text, :zone
|
|
37
|
+
|
|
38
|
+
# @api private: rules come from recurrence and from_cron.
|
|
39
|
+
# start: local wall seconds when timed, else a day number; until_: a UTC epoch when timed, else a day number;
|
|
40
|
+
# exdates: as start; dst: the dst: policy occurrences follow (R18); now: the epoch it was read at
|
|
41
|
+
def initialize(parts, zone, start, timed, count, until_, exdates, started, dst = nil, now:, text:)
|
|
42
|
+
@parts = parts
|
|
43
|
+
@zone = zone
|
|
44
|
+
@start = start
|
|
45
|
+
@timed = timed
|
|
46
|
+
@count = count
|
|
47
|
+
@until = until_
|
|
48
|
+
@exdates = exdates
|
|
49
|
+
@started = started
|
|
50
|
+
@dst = dst
|
|
51
|
+
@now = now
|
|
52
|
+
@text = -text
|
|
53
|
+
Ractor.make_shareable(self)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def kind = :rule
|
|
57
|
+
|
|
58
|
+
# The now it was read at, in its zone: where next counts from (R16)
|
|
59
|
+
def now = Time.at(@now, in: Zone.for(@zone).offset_at(@now))
|
|
60
|
+
|
|
61
|
+
def rrule
|
|
62
|
+
p = @parts
|
|
63
|
+
out = ["FREQ=#{p.freq.upcase}"]
|
|
64
|
+
out << "INTERVAL=#{p.interval}" if p.interval > 1
|
|
65
|
+
out << "BYMONTH=#{p.month}" if p.month
|
|
66
|
+
out << "BYMONTHDAY=#{p.monthdays.join(",")}" unless p.monthdays.empty?
|
|
67
|
+
byday = p.nths.map { |n, w| "#{n}#{DAYS[w]}" } + sorted_days.map { DAYS[_1] }
|
|
68
|
+
out << "BYDAY=#{byday.join(",")}" unless byday.empty?
|
|
69
|
+
out << "BYSETPOS=#{p.setpos.join(",")}" if p.setpos
|
|
70
|
+
unless p.secs.empty?
|
|
71
|
+
out << "BYHOUR=#{p.secs.map { _1 / 3600 }.uniq.join(",")}"
|
|
72
|
+
out << "BYMINUTE=#{p.secs.map { _1 / 60 % 60 }.uniq.sort.join(",")}"
|
|
73
|
+
out << "BYSECOND=#{p.secs[0] % 60}" if p.secs[0] % 60 != 0
|
|
74
|
+
end
|
|
75
|
+
out << "COUNT=#{@count}" if @count
|
|
76
|
+
out << "UNTIL=#{@timed ? Time.at(@until).utc.strftime("%Y%m%dT%H%M%SZ") : ymd(@until)}" if @until
|
|
77
|
+
out.join(";")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A Time in the zone when the rule has a clock, else a Date
|
|
81
|
+
def dtstart
|
|
82
|
+
return ::Date.new(*civil(@start)) unless @timed
|
|
83
|
+
utc = Zone.for(@zone).local_to_utc(@start, Notes.new)
|
|
84
|
+
Time.at(utc, in: Zone.for(@zone).offset_at(utc))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_ical
|
|
88
|
+
lines = ["DTSTART#{form}:#{stamp(@start)}", "RRULE:#{rrule}"]
|
|
89
|
+
lines << "EXDATE#{form}:#{@exdates.map { stamp(_1) }.join(",")}" unless @exdates.empty?
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_s
|
|
94
|
+
p = @parts
|
|
95
|
+
if (window = steps)
|
|
96
|
+
return "#{window}#{tail}" if p.freq == :daily && p.interval == 1
|
|
97
|
+
return "#{window} on #{weekdays(plural: true)}#{tail}" if p.freq == :weekly && p.interval == 1 && !p.days.empty?
|
|
98
|
+
end
|
|
99
|
+
out = +if !p.nths.empty?
|
|
100
|
+
"the #{list(p.nths.sort_by(&:first).map { ordinal(_1[0]) })} #{DAY_NAMES[p.nths[0][1]]} of #{every("month")}"
|
|
101
|
+
elsif p.setpos
|
|
102
|
+
"the #{list(p.setpos.map { ordinal(_1) })} weekday of #{every("month")}"
|
|
103
|
+
elsif p.month
|
|
104
|
+
"#{every("year")} on #{MONTH_NAMES[p.month]} #{p.monthdays[0]}"
|
|
105
|
+
elsif !p.monthdays.empty?
|
|
106
|
+
"the #{list(p.monthdays.map { _1 == -1 ? "last day" : nth(_1) })} of #{every("month")}"
|
|
107
|
+
elsif !p.days.empty?
|
|
108
|
+
"#{every(nil)} #{weekdays}"
|
|
109
|
+
else
|
|
110
|
+
every(UNITS[p.freq])
|
|
111
|
+
end
|
|
112
|
+
if window then out << " #{window}"
|
|
113
|
+
elsif !p.secs.empty? then out << " at #{list(p.secs.map { Describe.clock(_1) })}"
|
|
114
|
+
end
|
|
115
|
+
out << tail
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_h
|
|
119
|
+
line, reason = cron_or_reason
|
|
120
|
+
{ kind: "rule", text: @text, description: to_s, rrule: rrule, dtstart: @timed ? dtstart.iso8601 : dtstart.to_s,
|
|
121
|
+
zone: @zone, ical: to_ical, cron: line, cron_reason: reason }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_json(*args) = to_h.to_json(*args)
|
|
125
|
+
|
|
126
|
+
def as_json(*) = JSON.parse(to_json)
|
|
127
|
+
|
|
128
|
+
def ==(other) = Rule === other && other.to_ical == to_ical
|
|
129
|
+
|
|
130
|
+
alias eql? ==
|
|
131
|
+
|
|
132
|
+
def hash = to_ical.hash
|
|
133
|
+
|
|
134
|
+
def inspect = "#<Acute::Rule #{to_ical.tr("\n", " ")}>"
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
Notes = Struct.new(:dst)
|
|
139
|
+
FINE = { hourly: true, minutely: true, secondly: true }.freeze
|
|
140
|
+
private_constant :DAY, :FINE, :DAYS, :DAY_NAMES, :MONTH_NAMES, :ORDINALS, :UNITS, :Notes
|
|
141
|
+
|
|
142
|
+
# The count, start and end, as to_s ends with them
|
|
143
|
+
def tail
|
|
144
|
+
out = +""
|
|
145
|
+
out << " #{@count} times" if @count
|
|
146
|
+
out << " starting #{date(@timed ? @start.div(DAY) : @start)}" if @started
|
|
147
|
+
out << " until #{@timed ? until_local : date(@until)}" if @until
|
|
148
|
+
out << " except #{list(exceptions)}" unless @exdates.empty?
|
|
149
|
+
out
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# R4: several minutes past the hour, evenly spaced through a window of the day, as "every 15 minutes from
|
|
153
|
+
# 9am to 6pm", its end left out (R27); nil for one minute, two times, or times no step reaches
|
|
154
|
+
def steps
|
|
155
|
+
secs = @parts.secs
|
|
156
|
+
return if secs.size < 3 || secs.map { _1 / 60 % 60 }.uniq.size < 2
|
|
157
|
+
step = secs[1] - secs[0]
|
|
158
|
+
return unless 3600 % step == 0 && secs.each_cons(2).all? { _2 - _1 == step }
|
|
159
|
+
stop = secs[-1] + step >= DAY ? "midnight" : Describe.clock(secs[-1] + step)
|
|
160
|
+
"every #{step / 60} minutes from #{Describe.clock(secs[0])} to #{stop}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def form = @timed ? ";TZID=#{@zone}" : ";VALUE=DATE"
|
|
164
|
+
|
|
165
|
+
def stamp(t)
|
|
166
|
+
return ymd(t) unless @timed
|
|
167
|
+
s = t % DAY
|
|
168
|
+
format("%sT%02d%02d%02d", ymd(t.div(DAY)), s / 3600, s / 60 % 60, s % 60)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def ymd(day) = format("%08d", Calendar.civil_from_days(day))
|
|
172
|
+
|
|
173
|
+
def civil(day)
|
|
174
|
+
x = Calendar.civil_from_days(day)
|
|
175
|
+
[x / 10_000, x / 100 % 100, x % 100]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def date(day)
|
|
179
|
+
y, m, d = civil(day)
|
|
180
|
+
"#{Result::MONTHS[m - 1]} #{d}, #{y}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# R12: the excepted dates. On an hourly or finer rule a day keeps each excepted time unless they are all
|
|
184
|
+
# of that day's occurrences, so it reads back to the same EXDATEs.
|
|
185
|
+
def exceptions
|
|
186
|
+
return @exdates.map { date(@timed ? _1.div(DAY) : _1) }.uniq unless FINE[@parts.freq]
|
|
187
|
+
@exdates.group_by { _1.div(DAY) }.flat_map do |day, walls|
|
|
188
|
+
walls.sort == day_walls(day) ? date(day) : walls.map { "#{date(day)} #{Describe.clock(_1 % DAY)}" }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The wall times this rule, without its exceptions, occurs at on a local day
|
|
193
|
+
def day_walls(day)
|
|
194
|
+
base = Rule.new(@parts, @zone, @start, @timed, @count, @until, [], @started, @dst, now: @now, text: @text)
|
|
195
|
+
lead = Time.at(day * DAY - 15 * 3600)
|
|
196
|
+
base.each(after: lead).lazy.map { _1.to_i + _1.utc_offset }.select { _1 >= day * DAY }
|
|
197
|
+
.take_while { _1 < (day + 1) * DAY }.to_a.uniq
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The day an UNTIL ends, or the instant when it isn't a day's last second (R10)
|
|
201
|
+
def until_local
|
|
202
|
+
t = @until + Zone.for(@zone).offset_at(@until)
|
|
203
|
+
t % DAY == DAY - 1 ? date(t.div(DAY)) : "#{date(t.div(DAY))} #{Describe.clock(t % DAY)}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def sorted_days = @parts.days.sort_by { (_1 + 6) % 7 }
|
|
207
|
+
|
|
208
|
+
def weekdays(plural: false)
|
|
209
|
+
days = sorted_days
|
|
210
|
+
s = plural ? "s" : ""
|
|
211
|
+
return "weekday#{s}" if days == [1, 2, 3, 4, 5]
|
|
212
|
+
return "weekend#{s}" if days == [6, 0]
|
|
213
|
+
list(days.map { |day| "#{DAY_NAMES[day]}#{s}" })
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# "every month", "every other week", "every 3 days"; unit nil for weekdays, which take the interval in weeks
|
|
217
|
+
def every(unit)
|
|
218
|
+
n = @parts.interval
|
|
219
|
+
return "every" if unit.nil? && n == 1
|
|
220
|
+
return "every #{unit}".rstrip if n == 1
|
|
221
|
+
return "every other #{unit}".rstrip if n == 2
|
|
222
|
+
unit ? "every #{n} #{unit}s" : "every #{n} weeks on"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ordinal(n) = n == -1 ? "last" : n < 0 ? "#{ORDINALS[-n]} to last" : ORDINALS[n]
|
|
226
|
+
|
|
227
|
+
def nth(n) = "#{n}#{n % 100 / 10 == 1 ? "th" : { 1 => "st", 2 => "nd", 3 => "rd" }.fetch(n % 10, "th")}"
|
|
228
|
+
|
|
229
|
+
def list(items) = items.size == 1 ? items[0] : "#{items[0..-2].join(", ")} and #{items[-1]}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
require_relative "rule/cron"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# GENERATED by bin/generate from data/assumptions.yml; do not edit
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Acute
|
|
5
|
+
module Tables
|
|
6
|
+
ASSUMPTIONS = {
|
|
7
|
+
bare_hour_window: ["D4", "no am/pm given: 6-11 read as am, 12 and 1-5 as pm; the other half of the day is an alternative", "no am/pm: 6-11 am, 12-5 pm; the other is an alternative"].freeze,
|
|
8
|
+
next_occurrence: ["D23", "that time has passed today, so it means the next one", "that time has passed today, so the next one"].freeze,
|
|
9
|
+
previous_occurrence: ["D23", "that time is still to come today, so in past context it means the previous one", "still to come today, so looking back, the previous one"].freeze,
|
|
10
|
+
equal_to_now: ["D36", "that time is now", "that is now"].freeze,
|
|
11
|
+
twelve_oclock: ["D30", "a time in the 12 o'clock hour without am/pm is read as pm", "the 12 o'clock hour without am/pm is pm"].freeze,
|
|
12
|
+
midnight_end_of_day: ["D6", "midnight with a day means the end of that day; its start is an alternative", "midnight is the day's end; its start is an alternative"].freeze,
|
|
13
|
+
machine_time: ["D29", "seconds were given, so the time is read as 24-hour", "seconds given, so read as 24-hour"].freeze,
|
|
14
|
+
tomorrow_after_midnight: ["D35", "\"tomorrow\" said before 04:00 means the coming day first; the calendar tomorrow is an alternative", "\"tomorrow\" before 4am: today; next day is an alternative"].freeze,
|
|
15
|
+
dst_repeated_time: ["D23", "that wall time happens twice as clocks go back; the earlier one is used", "occurs twice as clocks go back; the earlier is used"].freeze,
|
|
16
|
+
dst_skipped_time: ["D23", "that wall time doesn't exist as clocks go forward; it is moved forward by the gap", "skipped as clocks go forward; moved on by the gap"].freeze,
|
|
17
|
+
approximate: ["D14", "the time was given as approximate; the exact time is returned", "approximate time; the exact time is given"].freeze,
|
|
18
|
+
fixed_offset: ["D18", "the text names a zone or offset, which wins over the zone given", "the text's zone wins over the zone given"].freeze,
|
|
19
|
+
ambiguous_zone: ["D38", "the zone abbreviation names more than one zone; each one's offset is a reading, in order", "ambiguous zone abbreviation; each zone is a reading"].freeze,
|
|
20
|
+
numeric_date_order: ["D10", "a numeric date is read in the locale's order; the other order is an alternative", "numeric date in locale order; the other is an alternative"].freeze,
|
|
21
|
+
part_of_day_point: ["D1", "a part of the day is a range; its time is a representative point", "a part of the day is a range; time is a point in it"].freeze,
|
|
22
|
+
weekend_friday_evening: ["D8", "the weekend is Saturday 00:00 to Monday 00:00; from Friday 18:00 is an alternative", "weekend is Sat-Sun; from Friday 6pm is an alternative"].freeze,
|
|
23
|
+
meteorological_season: ["D3", "seasons are meteorological (three whole months) for the locale's hemisphere", "seasons are whole months, for the locale's hemisphere"].freeze,
|
|
24
|
+
century_from_00: ["D12", "a century runs from its 00 year, as most people count it; the 01-to-00 reading is an alternative", "a century starts at year 00; from 01 is an alternative"].freeze,
|
|
25
|
+
decade_or_century: ["D12", "a decade at a century's start (\"the 2000s\") is the decade; the whole century is an alternative", "the decade is used; the whole century is an alternative"].freeze,
|
|
26
|
+
season_year: ["D3", "a season that runs over New Year belongs to the year it starts in; the one ending in that year is an alternative", "season by its start year; the end year is an alternative"].freeze,
|
|
27
|
+
range_under_way: ["D11", "the range is under way, so it means the current one; the coming one is an alternative", "under way, so the current one; the next is an alternative"].freeze,
|
|
28
|
+
same_day_or_next: ["D11", "that day is today, so it means today; the next one is an alternative", "that day is today; the next one is an alternative"].freeze,
|
|
29
|
+
same_day_passed: ["D11", "that day is today but today's has passed (or, said looking back, has not come yet), so the other one comes first; today's is an alternative", "the next (or previous) one first; today's is an alternative"].freeze,
|
|
30
|
+
next_two_readings: ["D20", "\"next\" can mean the coming one or the one after; both are given, the coming one first", "\"next\": the coming one; the one after is an alternative"].freeze,
|
|
31
|
+
this_passed: ["D21", "that one has passed in the current period, so the next one comes first; the passed one is an alternative", "passed, so the next one; the passed one is an alternative"].freeze,
|
|
32
|
+
rolling_window: ["D31", "a span counted from now; whole calendar units are alternatives", "counted from now; calendar units are alternatives"].freeze,
|
|
33
|
+
zone_inferred: [nil, "no zone was given; the zone comes from now, Time.zone or the system", "no zone given, so a default zone is used"].freeze,
|
|
34
|
+
weekday_mismatch: ["D37", "the weekday given doesn't fall on that date, so the date is used", "the weekday doesn't match the date; the date is used"].freeze,
|
|
35
|
+
also_duration: ["D45", "the text also reads as a length of time, which Acute.duration gives; read takes the time", "also reads as a length of time; this is the time"].freeze,
|
|
36
|
+
unknown_words_skipped: ["D22", "strict: false skipped words acute doesn't know; this is the first time in the rest of the text", "skipped unknown words; first time in the rest"].freeze,
|
|
37
|
+
}.freeze
|
|
38
|
+
end
|
|
39
|
+
end
|