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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/data/assumptions.yml +124 -0
  4. data/data/words.yml +699 -0
  5. data/exe/acute +4 -0
  6. data/lib/acute/assumption.rb +13 -0
  7. data/lib/acute/calendar.rb +91 -0
  8. data/lib/acute/chronic.rb +176 -0
  9. data/lib/acute/chronic_duration.rb +321 -0
  10. data/lib/acute/cli.rb +168 -0
  11. data/lib/acute/context.rb +205 -0
  12. data/lib/acute/describe.rb +316 -0
  13. data/lib/acute/duration.rb +120 -0
  14. data/lib/acute/errors.rb +47 -0
  15. data/lib/acute/explain.rb +165 -0
  16. data/lib/acute/extract.rb +493 -0
  17. data/lib/acute/grammar/clock_words.rb +109 -0
  18. data/lib/acute/grammar/compact.rb +222 -0
  19. data/lib/acute/grammar/duration.rb +93 -0
  20. data/lib/acute/grammar/nesting.rb +104 -0
  21. data/lib/acute/grammar/numbers.rb +378 -0
  22. data/lib/acute/grammar/ordinals.rb +101 -0
  23. data/lib/acute/grammar/ranges.rb +112 -0
  24. data/lib/acute/grammar/rule.rb +277 -0
  25. data/lib/acute/grammar/shifts.rb +226 -0
  26. data/lib/acute/grammar/words.rb +307 -0
  27. data/lib/acute/grammar.rb +378 -0
  28. data/lib/acute/holidays.rb +97 -0
  29. data/lib/acute/lexer.rb +487 -0
  30. data/lib/acute/locale.rb +53 -0
  31. data/lib/acute/mcp.rb +224 -0
  32. data/lib/acute/parser.rb +66 -0
  33. data/lib/acute/read.rb +21 -0
  34. data/lib/acute/resolve/clock.rb +75 -0
  35. data/lib/acute/resolve/dates.rb +75 -0
  36. data/lib/acute/resolve/day.rb +24 -0
  37. data/lib/acute/resolve/fuzzy.rb +91 -0
  38. data/lib/acute/resolve/named.rb +116 -0
  39. data/lib/acute/resolve/nth.rb +47 -0
  40. data/lib/acute/resolve/period.rb +160 -0
  41. data/lib/acute/resolve/periodic.rb +274 -0
  42. data/lib/acute/resolve/ranges.rb +165 -0
  43. data/lib/acute/resolve/recur.rb +187 -0
  44. data/lib/acute/resolve/rel.rb +146 -0
  45. data/lib/acute/resolve/shift.rb +81 -0
  46. data/lib/acute/resolve.rb +245 -0
  47. data/lib/acute/result.rb +128 -0
  48. data/lib/acute/rule/cron.rb +222 -0
  49. data/lib/acute/rule/expand.rb +401 -0
  50. data/lib/acute/rule.rb +232 -0
  51. data/lib/acute/tables/assumptions.rb +39 -0
  52. data/lib/acute/tables/words.rb +656 -0
  53. data/lib/acute/tables/zones.rb +930 -0
  54. data/lib/acute/tool/compact.rb +82 -0
  55. data/lib/acute/tool/ruby_llm.rb +29 -0
  56. data/lib/acute/tool.rb +192 -0
  57. data/lib/acute/verify.rb +216 -0
  58. data/lib/acute/version.rb +3 -0
  59. data/lib/acute/zone.rb +252 -0
  60. data/lib/acute.rb +160 -0
  61. data/schema/description.json +12 -0
  62. data/schema/duration.json +26 -0
  63. data/schema/extraction.json +30 -0
  64. data/schema/failure.json +29 -0
  65. data/schema/now.json +17 -0
  66. data/schema/result.json +45 -0
  67. data/schema/rule.json +19 -0
  68. data/schema/tools/describe.json +13 -0
  69. data/schema/tools/duration.json +12 -0
  70. data/schema/tools/extract.json +13 -0
  71. data/schema/tools/now.json +9 -0
  72. data/schema/tools/parse.json +13 -0
  73. data/schema/tools/recurrence.json +12 -0
  74. data/schema/tools/verify.json +14 -0
  75. data/schema/verdict.json +25 -0
  76. 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