master
go 378 lines 7.89 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package timeperiod
4
5 import (
6 "fmt"
7 "strings"
8 "time"
9 )
10
11 // Set contains compiled periods.
12 type Set struct {
13 periods map[string]*Period
14 }
15
16 // Period represents a compiled schedule.
17 type Period struct {
18 Name string
19 Alias string
20 rules []rule
21 excludes []*Period
22 }
23
24 type rule interface {
25 Allows(time.Time) bool
26 }
27
28 type weeklyRule struct {
29 perDay map[time.Weekday][]minuteRange
30 }
31
32 type nthWeekdayRule struct {
33 weekday time.Weekday
34 nth int
35 ranges []minuteRange
36 }
37
38 type dateRule struct {
39 dates map[string][]minuteRange // YYYY-MM-DD -> ranges
40 }
41
42 type minuteRange struct {
43 start int
44 end int
45 }
46
47 // Compile builds a Set from raw configs.
48 func Compile(cfgs []Config) (*Set, error) {
49 periods := make(map[string]*Period, len(cfgs))
50 for _, cfg := range cfgs {
51 if cfg.Name == "" {
52 return nil, fmt.Errorf("time_period missing name")
53 }
54 if _, exists := periods[cfg.Name]; exists {
55 return nil, fmt.Errorf("duplicate time_period '%s'", cfg.Name)
56 }
57 pr, err := compilePeriod(cfg)
58 if err != nil {
59 return nil, fmt.Errorf("time_period '%s': %w", cfg.Name, err)
60 }
61 periods[cfg.Name] = pr
62 }
63 // Resolve excludes
64 for name, p := range periods {
65 for _, exName := range cfgs[findConfigIndex(cfgs, name)].Exclude {
66 ex, ok := periods[exName]
67 if !ok {
68 return nil, fmt.Errorf("time_period '%s': exclude '%s' not found", name, exName)
69 }
70 p.excludes = append(p.excludes, ex)
71 }
72 }
73 return &Set{periods: periods}, nil
74 }
75
76 func findConfigIndex(cfgs []Config, name string) int {
77 for i, cfg := range cfgs {
78 if cfg.Name == name {
79 return i
80 }
81 }
82 return -1
83 }
84
85 func compilePeriod(cfg Config) (*Period, error) {
86 if len(cfg.Rules) == 0 {
87 return nil, fmt.Errorf("time_period '%s' needs at least one rule", cfg.Name)
88 }
89 pr := &Period{Name: cfg.Name, Alias: cfg.Alias}
90 for _, rc := range cfg.Rules {
91 var r rule
92 var err error
93 switch strings.ToLower(rc.Type) {
94 case "weekly", "":
95 r, err = compileWeeklyRule(rc)
96 case "nth_weekday":
97 r, err = compileNthWeekdayRule(rc)
98 case "date":
99 r, err = compileDateRule(rc)
100 default:
101 return nil, fmt.Errorf("unsupported rule type '%s'", rc.Type)
102 }
103 if err != nil {
104 return nil, err
105 }
106 pr.rules = append(pr.rules, r)
107 }
108 return pr, nil
109 }
110
111 func compileWeeklyRule(rc RuleConfig) (rule, error) {
112 if len(rc.Ranges) == 0 {
113 return nil, fmt.Errorf("weekly rule requires ranges")
114 }
115 daySet := rc.Days
116 if len(daySet) == 0 {
117 daySet = []string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"}
118 }
119 m := make(map[time.Weekday][]minuteRange)
120 ranges, err := parseRanges(rc.Ranges)
121 if err != nil {
122 return nil, err
123 }
124 for _, d := range daySet {
125 wd, err := parseWeekday(d)
126 if err != nil {
127 return nil, err
128 }
129 m[wd] = append([]minuteRange{}, ranges...)
130 }
131 return &weeklyRule{perDay: m}, nil
132 }
133
134 func compileNthWeekdayRule(rc RuleConfig) (rule, error) {
135 if rc.Weekday == "" || rc.Nth <= 0 {
136 return nil, fmt.Errorf("nth_weekday rule requires weekday and nth > 0")
137 }
138 if rc.Nth > 5 {
139 return nil, fmt.Errorf("nth_weekday nth must be <= 5")
140 }
141 ranges, err := parseRanges(rc.Ranges)
142 if err != nil {
143 return nil, err
144 }
145 wd, err := parseWeekday(rc.Weekday)
146 if err != nil {
147 return nil, err
148 }
149 return &nthWeekdayRule{weekday: wd, nth: rc.Nth, ranges: ranges}, nil
150 }
151
152 func compileDateRule(rc RuleConfig) (rule, error) {
153 if len(rc.Dates) == 0 {
154 return nil, fmt.Errorf("date rule requires dates")
155 }
156 ranges, err := parseRanges(rc.Ranges)
157 if err != nil {
158 return nil, err
159 }
160 m := make(map[string][]minuteRange)
161 for _, ds := range rc.Dates {
162 if _, err := time.Parse("2006-01-02", ds); err != nil {
163 return nil, fmt.Errorf("invalid date '%s'", ds)
164 }
165 key := ds
166 m[key] = append([]minuteRange{}, ranges...)
167 }
168 return &dateRule{dates: m}, nil
169 }
170
171 func parseRanges(list []string) ([]minuteRange, error) {
172 if len(list) == 0 {
173 return nil, fmt.Errorf("at least one range is required")
174 }
175 res := make([]minuteRange, 0, len(list))
176 for _, item := range list {
177 parts := strings.Split(item, "-")
178 if len(parts) != 2 {
179 return nil, fmt.Errorf("invalid range '%s'", item)
180 }
181 s, err := parseMinute(parts[0])
182 if err != nil {
183 return nil, err
184 }
185 e, err := parseMinute(parts[1])
186 if err != nil {
187 return nil, err
188 }
189 if e < s {
190 return nil, fmt.Errorf("range '%s' end before start", item)
191 }
192 res = append(res, minuteRange{start: s, end: e})
193 }
194 return res, nil
195 }
196
197 func parseMinute(val string) (int, error) {
198 parts := strings.Split(val, ":")
199 if len(parts) != 2 {
200 return 0, fmt.Errorf("invalid time '%s'", val)
201 }
202 hour, err := parseIntBound(parts[0], 0, 24)
203 if err != nil {
204 return 0, err
205 }
206 min, err := parseIntBound(parts[1], 0, 59)
207 if err != nil {
208 return 0, err
209 }
210 if hour == 24 && min != 0 {
211 return 0, fmt.Errorf("24:%02d is invalid", min)
212 }
213 return hour*60 + min, nil
214 }
215
216 func parseIntBound(val string, min, max int) (int, error) {
217 var x int
218 _, err := fmt.Sscanf(val, "%d", &x)
219 if err != nil {
220 return 0, fmt.Errorf("invalid number '%s'", val)
221 }
222 if x < min || x > max {
223 return 0, fmt.Errorf("value '%s' out of bounds", val)
224 }
225 return x, nil
226 }
227
228 func parseWeekday(val string) (time.Weekday, error) {
229 switch strings.ToLower(val) {
230 case "sunday":
231 return time.Sunday, nil
232 case "monday":
233 return time.Monday, nil
234 case "tuesday":
235 return time.Tuesday, nil
236 case "wednesday":
237 return time.Wednesday, nil
238 case "thursday":
239 return time.Thursday, nil
240 case "friday":
241 return time.Friday, nil
242 case "saturday":
243 return time.Saturday, nil
244 default:
245 return time.Sunday, fmt.Errorf("invalid weekday '%s'", val)
246 }
247 }
248
249 // Resolve returns the compiled period for a name.
250 func (s *Set) Resolve(name string) (*Period, error) {
251 if s == nil || name == "" {
252 return nil, nil
253 }
254 per, ok := s.periods[name]
255 if !ok {
256 return nil, fmt.Errorf("time_period '%s' not defined", name)
257 }
258 return per, nil
259 }
260
261 // Allows determines whether the time falls inside the period (excluding child exclusions).
262 func (p *Period) Allows(t time.Time) bool {
263 return p.allows(t, make(map[*Period]bool))
264 }
265
266 func (p *Period) allows(t time.Time, stack map[*Period]bool) bool {
267 if p == nil {
268 return true
269 }
270 if stack[p] {
271 return false
272 }
273 stack[p] = true
274 defer delete(stack, p)
275
276 allowed := false
277 for _, r := range p.rules {
278 if r.Allows(t) {
279 allowed = true
280 break
281 }
282 }
283 if !allowed {
284 return false
285 }
286 for _, ex := range p.excludes {
287 if ex == nil || ex == p {
288 continue
289 }
290 if ex.allows(t, stack) {
291 return false
292 }
293 }
294 return true
295 }
296
297 // NextAllowed returns the next timestamp at or after t that is allowed.
298 func (p *Period) NextAllowed(t time.Time) time.Time {
299 if p == nil {
300 return t
301 }
302 for range 60 * 24 * 90 { // search up to ~90 days
303 if p.Allows(t) {
304 return t
305 }
306 t = t.Add(time.Minute)
307 }
308 return time.Time{}
309 }
310
311 // rule implementations
312
313 func (r *weeklyRule) Allows(t time.Time) bool {
314 if r == nil {
315 return false
316 }
317 min := t.Hour()*60 + t.Minute()
318 list := r.perDay[t.Weekday()]
319 for _, rng := range list {
320 if rng.contains(min) {
321 return true
322 }
323 }
324 return false
325 }
326
327 func (mr minuteRange) contains(min int) bool {
328 return min >= mr.start && min < mr.end
329 }
330
331 func (r *nthWeekdayRule) Allows(t time.Time) bool {
332 if r == nil {
333 return false
334 }
335 if t.Weekday() != r.weekday {
336 return false
337 }
338 day := t.Day()
339 nth := (day-1)/7 + 1
340 if nth != r.nth {
341 return false
342 }
343 min := t.Hour()*60 + t.Minute()
344 for _, rng := range r.ranges {
345 if rng.contains(min) {
346 return true
347 }
348 }
349 return false
350 }
351
352 func (r *dateRule) Allows(t time.Time) bool {
353 if r == nil {
354 return false
355 }
356 key := t.Format("2006-01-02")
357 list := r.dates[key]
358 if len(list) == 0 {
359 return false
360 }
361 min := t.Hour()*60 + t.Minute()
362 for _, rng := range list {
363 if rng.contains(min) {
364 return true
365 }
366 }
367 return false
368 }
369
370 // EnsureDefault appends the builtin period when missing.
371 func EnsureDefault(cfgs []Config) []Config {
372 for _, cfg := range cfgs {
373 if cfg.Name == DefaultPeriodName {
374 return cfgs
375 }
376 }
377 return append(cfgs, DefaultPeriodConfig())
378 }