// Package schedule provides types for scheduling. package schedule import ( "encoding/json" "fmt" "time" "github.com/AdguardTeam/AdGuardHome/internal/aghhttp" "github.com/AdguardTeam/golibs/errors" "github.com/AdguardTeam/golibs/timeutil" "gopkg.in/yaml.v3" ) // Weekly is a schedule for one week. Each day of the week has one range with // a beginning and an end. type Weekly struct { // location is used to calculate the offsets of the day ranges. location *time.Location // days are the day ranges of this schedule. The indexes of this array are // the [time.Weekday] values. days [7]dayRange } // EmptyWeekly creates empty weekly schedule with local time zone. func EmptyWeekly() (w *Weekly) { return &Weekly{ location: time.Local, } } // FullWeekly creates full weekly schedule with local time zone. // // TODO(s.chzhen): Consider moving into tests. func FullWeekly() (w *Weekly) { fullDay := dayRange{start: 0, end: maxDayRange} return &Weekly{ location: time.Local, days: [7]dayRange{ time.Sunday: fullDay, time.Monday: fullDay, time.Tuesday: fullDay, time.Wednesday: fullDay, time.Thursday: fullDay, time.Friday: fullDay, time.Saturday: fullDay, }, } } // Clone returns a deep copy of a weekly. func (w *Weekly) Clone() (c *Weekly) { if w == nil { return nil } // NOTE: Do not use time.LoadLocation, because the results will be // different on time zone database update. return &Weekly{ location: w.location, days: w.days, } } // Contains returns true if t is within the corresponding day range of the // schedule in the schedule's time zone. func (w *Weekly) Contains(t time.Time) (ok bool) { t = t.In(w.location) wd := t.Weekday() dr := w.days[wd] // Calculate the offset of the day range. // // NOTE: Do not use [time.Truncate] since it requires UTC time zone. y, m, d := t.Date() day := time.Date(y, m, d, 0, 0, 0, 0, w.location) offset := t.Sub(day) return dr.contains(offset) } // type check var _ json.Unmarshaler = (*Weekly)(nil) // UnmarshalJSON implements the [json.Unmarshaler] interface for *Weekly. func (w *Weekly) UnmarshalJSON(data []byte) (err error) { conf := &weeklyConfigJSON{} err = json.Unmarshal(data, conf) if err != nil { return err } weekly := Weekly{} weekly.location, err = time.LoadLocation(conf.TimeZone) if err != nil { return err } days := []*dayConfigJSON{ time.Sunday: conf.Sunday, time.Monday: conf.Monday, time.Tuesday: conf.Tuesday, time.Wednesday: conf.Wednesday, time.Thursday: conf.Thursday, time.Friday: conf.Friday, time.Saturday: conf.Saturday, } for i, d := range days { var r dayRange if d != nil { r = dayRange{ start: time.Duration(d.Start), end: time.Duration(d.End), } } err = w.validate(r) if err != nil { return fmt.Errorf("weekday %s: %w", time.Weekday(i), err) } weekly.days[i] = r } *w = weekly return nil } // type check var _ yaml.Unmarshaler = (*Weekly)(nil) // UnmarshalYAML implements the [yaml.Unmarshaler] interface for *Weekly. func (w *Weekly) UnmarshalYAML(value *yaml.Node) (err error) { conf := &weeklyConfigYAML{} err = value.Decode(conf) if err != nil { // Don't wrap the error since it's informative enough as is. return err } weekly := Weekly{} weekly.location, err = time.LoadLocation(conf.TimeZone) if err != nil { // Don't wrap the error since it's informative enough as is. return err } days := []dayConfigYAML{ time.Sunday: conf.Sunday, time.Monday: conf.Monday, time.Tuesday: conf.Tuesday, time.Wednesday: conf.Wednesday, time.Thursday: conf.Thursday, time.Friday: conf.Friday, time.Saturday: conf.Saturday, } for i, d := range days { r := dayRange{ start: d.Start.Duration, end: d.End.Duration, } err = w.validate(r) if err != nil { return fmt.Errorf("weekday %s: %w", time.Weekday(i), err) } weekly.days[i] = r } *w = weekly return nil } // weeklyConfigYAML is the YAML configuration structure of Weekly. type weeklyConfigYAML struct { // TimeZone is the local time zone. TimeZone string `yaml:"time_zone"` // Days of the week. Sunday dayConfigYAML `yaml:"sun,omitempty"` Monday dayConfigYAML `yaml:"mon,omitempty"` Tuesday dayConfigYAML `yaml:"tue,omitempty"` Wednesday dayConfigYAML `yaml:"wed,omitempty"` Thursday dayConfigYAML `yaml:"thu,omitempty"` Friday dayConfigYAML `yaml:"fri,omitempty"` Saturday dayConfigYAML `yaml:"sat,omitempty"` } // dayConfigYAML is the YAML configuration structure of dayRange. type dayConfigYAML struct { Start timeutil.Duration `yaml:"start"` End timeutil.Duration `yaml:"end"` } // maxDayRange is the maximum value for day range end. const maxDayRange = 24 * time.Hour // validate returns the day range rounding errors, if any. func (w *Weekly) validate(r dayRange) (err error) { defer func() { err = errors.Annotate(err, "bad day range: %w") }() err = r.validate() if err != nil { // Don't wrap the error since it's informative enough as is. return err } start := r.start.Truncate(time.Minute) end := r.end.Truncate(time.Minute) switch { case start != r.start: return fmt.Errorf("start %s isn't rounded to minutes", r.start) case end != r.end: return fmt.Errorf("end %s isn't rounded to minutes", r.end) default: return nil } } // type check var _ json.Marshaler = (*Weekly)(nil) // MarshalJSON implements the [json.Marshaler] interface for *Weekly. func (w *Weekly) MarshalJSON() (data []byte, err error) { c := &weeklyConfigJSON{ TimeZone: w.location.String(), Sunday: w.days[time.Sunday].toDayConfigJSON(), Monday: w.days[time.Monday].toDayConfigJSON(), Tuesday: w.days[time.Tuesday].toDayConfigJSON(), Wednesday: w.days[time.Wednesday].toDayConfigJSON(), Thursday: w.days[time.Thursday].toDayConfigJSON(), Friday: w.days[time.Friday].toDayConfigJSON(), Saturday: w.days[time.Saturday].toDayConfigJSON(), } return json.Marshal(c) } // type check var _ yaml.Marshaler = (*Weekly)(nil) // MarshalYAML implements the [yaml.Marshaler] interface for *Weekly. func (w *Weekly) MarshalYAML() (v any, err error) { return weeklyConfigYAML{ TimeZone: w.location.String(), Sunday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Sunday].start}, End: timeutil.Duration{Duration: w.days[time.Sunday].end}, }, Monday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Monday].start}, End: timeutil.Duration{Duration: w.days[time.Monday].end}, }, Tuesday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Tuesday].start}, End: timeutil.Duration{Duration: w.days[time.Tuesday].end}, }, Wednesday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Wednesday].start}, End: timeutil.Duration{Duration: w.days[time.Wednesday].end}, }, Thursday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Thursday].start}, End: timeutil.Duration{Duration: w.days[time.Thursday].end}, }, Friday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Friday].start}, End: timeutil.Duration{Duration: w.days[time.Friday].end}, }, Saturday: dayConfigYAML{ Start: timeutil.Duration{Duration: w.days[time.Saturday].start}, End: timeutil.Duration{Duration: w.days[time.Saturday].end}, }, }, nil } // dayRange represents a single interval within a day. The interval begins at // start and ends before end. That is, it contains a time point T if start <= // T < end. type dayRange struct { // start is an offset from the beginning of the day. It must be greater // than or equal to zero and less than 24h. start time.Duration // end is an offset from the beginning of the day. It must be greater than // or equal to zero and less than or equal to 24h. end time.Duration } // validate returns the day range validation errors, if any. func (r dayRange) validate() (err error) { switch { case r == dayRange{}: return nil case r.start < 0: return fmt.Errorf("start %s is negative", r.start) case r.end < 0: return fmt.Errorf("end %s is negative", r.end) case r.start >= r.end: return fmt.Errorf("start %s is greater or equal to end %s", r.start, r.end) case r.start >= maxDayRange: return fmt.Errorf("start %s is greater or equal to %s", r.start, maxDayRange) case r.end > maxDayRange: return fmt.Errorf("end %s is greater than %s", r.end, maxDayRange) default: return nil } } // contains returns true if start <= offset < end, where offset is the time // duration from the beginning of the day. func (r *dayRange) contains(offset time.Duration) (ok bool) { return r.start <= offset && offset < r.end } // toDayConfigJSON returns nil if the day range is empty, otherwise returns // initialized JSON configuration of the day range. func (r dayRange) toDayConfigJSON() (j *dayConfigJSON) { if (r == dayRange{}) { return nil } return &dayConfigJSON{ Start: aghhttp.JSONDuration(r.start), End: aghhttp.JSONDuration(r.end), } } // weeklyConfigJSON is the JSON configuration structure of Weekly. type weeklyConfigJSON struct { // Days of the week. Sunday *dayConfigJSON `json:"sun,omitempty"` Monday *dayConfigJSON `json:"mon,omitempty"` Tuesday *dayConfigJSON `json:"tue,omitempty"` Wednesday *dayConfigJSON `json:"wed,omitempty"` Thursday *dayConfigJSON `json:"thu,omitempty"` Friday *dayConfigJSON `json:"fri,omitempty"` Saturday *dayConfigJSON `json:"sat,omitempty"` // TimeZone is the local time zone. TimeZone string `json:"time_zone"` } // dayConfigJSON is the JSON configuration structure of dayRange. type dayConfigJSON struct { Start aghhttp.JSONDuration `json:"start"` End aghhttp.JSONDuration `json:"end"` }