github.com/rigado/snapd@v2.42.5-go-mod+incompatible/timeutil/schedule.go (about) 1 // -*- Mode: Go; indent-tabs-mode: t -*- 2 3 /* 4 * Copyright (C) 2017 Canonical Ltd 5 * 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License version 3 as 8 * published by the Free Software Foundation. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <http://www.gnu.org/licenses/>. 17 * 18 */ 19 20 package timeutil 21 22 import ( 23 "bytes" 24 "fmt" 25 "math/rand" 26 "regexp" 27 "strconv" 28 "strings" 29 "time" 30 ) 31 32 // Match 0:00-24:00, where 24:00 means the later end of the day. 33 var validTime = regexp.MustCompile(`^([0-9]|0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])$|^24:00$`) 34 35 // Clock represents a hour:minute time within a day. 36 type Clock struct { 37 Hour int 38 Minute int 39 } 40 41 func (t Clock) String() string { 42 return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) 43 } 44 45 // Sub returns the duration t - other. 46 func (t Clock) Sub(other Clock) time.Duration { 47 t1 := time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute 48 t2 := time.Duration(other.Hour)*time.Hour + time.Duration(other.Minute)*time.Minute 49 dur := t1 - t2 50 if dur < 0 { 51 dur = -(dur + 24*time.Hour) 52 } 53 return dur 54 } 55 56 // Add adds given duration to t and returns a new Clock 57 func (t Clock) Add(dur time.Duration) Clock { 58 t1 := time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute 59 t2 := t1 + dur 60 nt := Clock{ 61 Hour: int(t2.Hours()) % 24, 62 Minute: int(t2.Minutes()) % 60, 63 } 64 return nt 65 } 66 67 // Time generates a time.Time with hour and minute set from t, while year, month 68 // and day are taken from base 69 func (t Clock) Time(base time.Time) time.Time { 70 return time.Date(base.Year(), base.Month(), base.Day(), 71 t.Hour, t.Minute, 0, 0, base.Location()) 72 } 73 74 // ParseClock parses a string that contains hour:minute and returns 75 // a Clock type or an error 76 func ParseClock(s string) (t Clock, err error) { 77 m := validTime.FindStringSubmatch(s) 78 if len(m) == 0 { 79 return t, fmt.Errorf("cannot parse %q", s) 80 } 81 82 if m[0] == "24:00" { 83 t.Hour = 24 84 return t, nil 85 } 86 87 t.Hour, err = strconv.Atoi(m[1]) 88 if err != nil { 89 return t, fmt.Errorf("cannot parse %q: %s", m[1], err) 90 } 91 t.Minute, err = strconv.Atoi(m[2]) 92 if err != nil { 93 return t, fmt.Errorf("cannot parse %q: %s", m[2], err) 94 } 95 return t, nil 96 } 97 98 const ( 99 EveryWeek uint = 0 100 LastWeek uint = 5 101 ) 102 103 // Week represents a weekday such as Monday, Tuesday, with optional 104 // week-in-the-month position, eg. the first Monday of the month 105 type Week struct { 106 Weekday time.Weekday 107 // Pos defines which week inside the month the Day refers to, where zero 108 // means every week, 1 means first occurrence of the weekday, and 5 109 // means last occurrence (which might be the fourth or the fifth). 110 Pos uint 111 } 112 113 func (w Week) String() string { 114 // Wednesday -> wed 115 day := strings.ToLower(w.Weekday.String()[0:3]) 116 if w.Pos == EveryWeek { 117 return day 118 } 119 return day + strconv.Itoa(int(w.Pos)) 120 } 121 122 // WeekSpan represents a span of weekdays between Start and End days. WeekSpan 123 // may wrap around the week, eg. fri-mon is a span from Friday to Monday 124 type WeekSpan struct { 125 Start Week 126 End Week 127 } 128 129 func (ws WeekSpan) String() string { 130 if ws.End != ws.Start { 131 return ws.Start.String() + "-" + ws.End.String() 132 } 133 return ws.Start.String() 134 } 135 136 func findNthWeekDay(t time.Time, weekday time.Weekday, nthInMonth uint) time.Time { 137 // move to the beginning of the month 138 t = t.AddDate(0, 0, -t.Day()+1) 139 140 var nth uint 141 for { 142 if t.Weekday() == weekday { 143 nth++ 144 if nth == nthInMonth { 145 break 146 } 147 } 148 t = t.Add(24 * time.Hour) 149 } 150 return t 151 } 152 153 // Match checks if t is within the day span represented by ws. 154 func (ws WeekSpan) Match(t time.Time) bool { 155 start, end := ws.Start, ws.End 156 wdStart, wdEnd := start.Weekday, end.Weekday 157 158 if start.Pos != EveryWeek { 159 if start.Pos == LastWeek { 160 // last week of the month 161 if !isLastWeekdayInMonth(t) { 162 return false 163 } 164 } else { 165 startDay := findNthWeekDay(t, start.Weekday, start.Pos) 166 endDay := findNthWeekDay(t, end.Weekday, end.Pos) 167 168 if t.Day() < startDay.Day() || t.Day() > endDay.Day() { 169 return false 170 } 171 return true 172 } 173 } 174 175 if wdStart <= wdEnd { 176 // single day (mon) or start < end (eg. mon-fri) 177 return t.Weekday() >= wdStart && t.Weekday() <= wdEnd 178 } 179 // wraps around the week end, eg. fri-mon 180 return t.Weekday() >= wdStart || t.Weekday() <= wdEnd 181 } 182 183 // ClockSpan represents a time span within 24h, potentially crossing days. For 184 // example, 23:00-1:00 represents a span from 11pm to 1am. 185 type ClockSpan struct { 186 Start Clock 187 End Clock 188 // Split defines the number of subspans this span will be divided into. 189 Split uint 190 // Spread defines whether the events are randomly spread inside the span 191 // or subspans. 192 Spread bool 193 } 194 195 func (ts ClockSpan) String() string { 196 sep := "-" 197 if ts.Spread { 198 sep = "~" 199 } 200 if ts.End != ts.Start { 201 s := ts.Start.String() + sep + ts.End.String() 202 if ts.Split > 0 { 203 s += "/" + strconv.Itoa(int(ts.Split)) 204 } 205 return s 206 } 207 return ts.Start.String() 208 } 209 210 // Window generates a ScheduleWindow which has the start date same as t. The 211 // window's start and end time are set according to Start and End, with the end 212 // time possibly crossing into the next day. 213 func (ts ClockSpan) Window(t time.Time) ScheduleWindow { 214 start := ts.Start.Time(t) 215 end := ts.End.Time(t) 216 217 // 23:00-1:00 218 if end.Before(start) { 219 end = end.Add(24 * time.Hour) 220 } 221 return ScheduleWindow{ 222 Start: start, 223 End: end, 224 Spread: ts.Spread, 225 } 226 } 227 228 // ClockSpans returns a slice of ClockSpans generated from ts by splitting the 229 // time between ts.Start and ts.End into ts.Split equal spans. 230 func (ts ClockSpan) ClockSpans() []ClockSpan { 231 if ts.Split == 0 || ts.Split == 1 || ts.End == ts.Start { 232 return []ClockSpan{ts} 233 } 234 235 span := ts.End.Sub(ts.Start) 236 if span < 0 { 237 span = -span 238 } 239 step := span / time.Duration(ts.Split) 240 241 spans := make([]ClockSpan, ts.Split) 242 for i := uint(0); i < ts.Split; i++ { 243 start := ts.Start.Add(time.Duration(i) * step) 244 spans[i] = ClockSpan{ 245 Start: start, 246 End: start.Add(step), 247 Split: 0, // no more subspans 248 Spread: ts.Spread, 249 } 250 } 251 return spans 252 } 253 254 // Schedule represents a single schedule 255 type Schedule struct { 256 WeekSpans []WeekSpan 257 ClockSpans []ClockSpan 258 } 259 260 func (sched *Schedule) String() string { 261 var buf bytes.Buffer 262 263 for i, span := range sched.WeekSpans { 264 if i > 0 { 265 buf.WriteByte(',') 266 } 267 buf.WriteString(span.String()) 268 } 269 270 if len(sched.WeekSpans) > 0 && len(sched.ClockSpans) > 0 { 271 buf.WriteByte(',') 272 } 273 274 for i, span := range sched.ClockSpans { 275 if i > 0 { 276 buf.WriteByte(',') 277 } 278 buf.WriteString(span.String()) 279 } 280 return buf.String() 281 } 282 283 func (sched *Schedule) flattenedClockSpans() []ClockSpan { 284 baseTimes := sched.ClockSpans 285 if len(baseTimes) == 0 { 286 baseTimes = []ClockSpan{{}} 287 } 288 289 times := make([]ClockSpan, 0, len(baseTimes)) 290 for _, ts := range baseTimes { 291 times = append(times, ts.ClockSpans()...) 292 } 293 return times 294 } 295 296 // isLastWeekdayInMonth returns true if t.Weekday() is the last weekday 297 // occurring this t.Month(), eg. check is Feb 25 2017 is the last Saturday of 298 // February. 299 func isLastWeekdayInMonth(t time.Time) bool { 300 // try a week from now, if it's still the same month then t.Weekday() is 301 // not last 302 return t.Month() != t.Add(7*24*time.Hour).Month() 303 } 304 305 // ScheduleWindow represents a time window between Start and End times when the 306 // scheduled event can happen. 307 type ScheduleWindow struct { 308 Start time.Time 309 End time.Time 310 // Spread defines whether the event shall be randomly placed between 311 // Start and End times 312 Spread bool 313 } 314 315 // Includes returns whether t is inside the window. 316 func (s ScheduleWindow) Includes(t time.Time) bool { 317 return !(t.Before(s.Start) || t.After(s.End)) 318 } 319 320 // IsZero returns whether s is uninitialized. 321 func (s ScheduleWindow) IsZero() bool { 322 return s.Start.IsZero() || s.End.IsZero() 323 } 324 325 // Next returns the earliest window after last according to the schedule. 326 func (sched *Schedule) Next(last time.Time) ScheduleWindow { 327 now := timeNow() 328 329 tspans := sched.flattenedClockSpans() 330 331 for t := last; ; t = t.Add(24 * time.Hour) { 332 // try to find a matching schedule by moving in 24h jumps, check 333 // if the event needs to happen on a specific day in a specific 334 // week, next pick the earliest event time 335 336 var window ScheduleWindow 337 338 if len(sched.WeekSpans) > 0 { 339 // if there's a week schedule, check if we hit that 340 // first 341 var weekMatch bool 342 for _, week := range sched.WeekSpans { 343 if week.Match(t) { 344 weekMatch = true 345 break 346 } 347 } 348 349 if !weekMatch { 350 continue 351 } 352 } 353 354 for _, tspan := range tspans { 355 // consider all time spans for this particular date and 356 // find the earliest possible one that is not before 357 // 'now', and does not include the 'last' time 358 newWindow := tspan.Window(t) 359 360 if newWindow.End.Before(now) { 361 // the time span ends before 'now', try another 362 // one 363 continue 364 } 365 366 if newWindow.Includes(last) { 367 // same interval as last update, move forward 368 continue 369 } 370 371 if window.IsZero() || newWindow.Start.Before(window.Start) { 372 // this candidate comes before current 373 // candidate, so use it 374 window = newWindow 375 } 376 } 377 if window.End.Before(now) { 378 // no suitable time span was found this day so try the 379 // next day 380 continue 381 } 382 return window 383 } 384 385 } 386 387 func randDur(a, b time.Time) time.Duration { 388 dur := b.Sub(a) 389 if dur > 5*time.Minute { 390 // doing it this way we still spread really small windows about 391 dur -= 5 * time.Minute 392 } 393 394 if dur <= 0 { 395 // avoid panic'ing (even if things are probably messed up) 396 return 0 397 } 398 399 return time.Duration(rand.Int63n(int64(dur))) 400 } 401 402 var ( 403 timeNow = time.Now 404 ) 405 406 func init() { 407 rand.Seed(time.Now().UnixNano()) 408 } 409 410 // Next returns the earliest event after last according to the provided 411 // schedule but no later than maxDuration since last. 412 func Next(schedule []*Schedule, last time.Time, maxDuration time.Duration) time.Duration { 413 now := timeNow() 414 415 window := ScheduleWindow{ 416 Start: last.Add(maxDuration), 417 End: last.Add(maxDuration).Add(1 * time.Hour), 418 } 419 420 for _, sched := range schedule { 421 next := sched.Next(last) 422 if next.Start.Before(window.Start) { 423 window = next 424 } 425 } 426 if window.Start.Before(now) { 427 return 0 428 } 429 430 when := window.Start.Sub(now) 431 if window.Spread { 432 when += randDur(window.Start, window.End) 433 } 434 435 return when 436 437 } 438 439 var weekdayMap = map[string]time.Weekday{ 440 "sun": time.Sunday, 441 "mon": time.Monday, 442 "tue": time.Tuesday, 443 "wed": time.Wednesday, 444 "thu": time.Thursday, 445 "fri": time.Friday, 446 "sat": time.Saturday, 447 } 448 449 // parseClockRange parses a string like "9:00-11:00" and returns the start and 450 // end times. 451 func parseClockRange(s string) (start, end Clock, err error) { 452 l := strings.SplitN(s, "-", 2) 453 if len(l) != 2 { 454 return start, end, fmt.Errorf("cannot parse %q: not a valid interval", s) 455 } 456 457 start, err = ParseClock(l[0]) 458 if err != nil { 459 return start, end, fmt.Errorf("cannot parse %q: not a valid time", l[0]) 460 } 461 end, err = ParseClock(l[1]) 462 if err != nil { 463 return start, end, fmt.Errorf("cannot parse %q: not a valid time", l[1]) 464 } 465 466 return start, end, nil 467 } 468 469 // ParseLegacySchedule takes an obsolete schedule string in the form of: 470 // 471 // 9:00-15:00 (every day between 9am and 3pm) 472 // 9:00-15:00/21:00-22:00 (every day between 9am,5pm and 9pm,10pm) 473 // 474 // and returns a list of Schedule types or an error 475 func ParseLegacySchedule(scheduleSpec string) ([]*Schedule, error) { 476 var schedule []*Schedule 477 478 for _, s := range strings.Split(scheduleSpec, "/") { 479 start, end, err := parseClockRange(s) 480 if err != nil { 481 return nil, err 482 } 483 schedule = append(schedule, &Schedule{ 484 ClockSpans: []ClockSpan{{ 485 Start: start, 486 End: end, 487 Spread: true, 488 }}, 489 }) 490 } 491 492 return schedule, nil 493 } 494 495 // ParseSchedule parses a schedule in V2 format. The format is described as: 496 // 497 // eventlist = eventset *( ",," eventset ) 498 // eventset = wdaylist / timelist / wdaylist "," timelist 499 // 500 // wdaylist = wdayset *( "," wdayset ) 501 // wdayset = wday / wdayspan 502 // wday = ( "sun" / "mon" / "tue" / "wed" / "thu" / "fri" / "sat" ) [ DIGIT ] 503 // wdayspan = wday "-" wday 504 // 505 // timelist = timeset *( "," timeset ) 506 // timeset = time / timespan 507 // time = 2DIGIT ":" 2DIGIT 508 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 509 // count = 1*DIGIT 510 // 511 // Examples: 512 // mon,10:00,,fri,15:00 (Monday at 10:00, Friday at 15:00) 513 // mon,fri,10:00,15:00 (Monday at 10:00 and 15:00, Friday at 10:00 and 15:00) 514 // mon-wed,fri,9:00-11:00/2 (Monday to Wednesday and on Friday, twice between 515 // 9:00 and 11:00) 516 // mon,9:00~11:00,,wed,22:00~23:00 (Monday, sometime between 9:00 and 11:00, and 517 // on Wednesday, sometime between 22:00 and 23:00) 518 // mon,wed (Monday and on Wednesday) 519 // mon,,wed (same as above) 520 // 521 // Returns a slice of schedules or an error if parsing failed 522 func ParseSchedule(scheduleSpec string) ([]*Schedule, error) { 523 var schedule []*Schedule 524 525 for _, s := range strings.Split(scheduleSpec, ",,") { 526 // cut the schedule in event sets 527 // eventlist = eventset *( ",," eventset ) 528 sched, err := parseEventSet(s) 529 if err != nil { 530 return nil, err 531 } 532 schedule = append(schedule, sched) 533 } 534 return schedule, nil 535 } 536 537 // parseWeekSpan parses a weekly span such as "mon-tue" or "mon2-tue3". 538 func parseWeekSpan(s string) (span WeekSpan, err error) { 539 var parsed WeekSpan 540 541 split := strings.Split(s, spanToken) 542 if len(split) > 2 { 543 return span, fmt.Errorf("cannot parse %q: invalid week span", s) 544 } 545 546 parsed.Start, err = parseWeekday(split[0]) 547 if err != nil { 548 return span, fmt.Errorf("cannot parse %q: %q is not a valid weekday", s, split[0]) 549 } 550 551 if len(split) == 2 { 552 parsed.End, err = parseWeekday(split[1]) 553 if err != nil { 554 return span, fmt.Errorf("cannot parse %q: %q is not a valid weekday", s, split[1]) 555 } 556 } else { 557 parsed.End = parsed.Start 558 } 559 560 if parsed.End.Pos < parsed.Start.Pos { 561 // eg. mon4-mon1 562 return span, fmt.Errorf("cannot parse %q: unsupported schedule", s) 563 } 564 565 if (parsed.Start.Pos != EveryWeek) != (parsed.End.Pos != EveryWeek) { 566 return span, fmt.Errorf("cannot parse %q: week number must be present for both weekdays or neither", s) 567 } 568 569 return parsed, nil 570 } 571 572 // parseClockSpan parses a time specification which can either be `<hh>:<mm>` or 573 // `<hh>:<mm>[-~]<hh>:<mm>[/count]`. Alternatively the span can be one of 574 // special tokens `-`, `~` (followed by an optional [/count]) that indicate a 575 // whole day span, or a whole day span with spread respectively. 576 func parseClockSpan(s string) (span ClockSpan, err error) { 577 var rest string 578 579 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 580 581 span.Split, rest, err = parseCount(s) 582 if err != nil { 583 return ClockSpan{}, fmt.Errorf("cannot parse %q: not a valid interval", s) 584 } 585 586 if strings.Contains(rest, spreadToken) { 587 // timespan uses "~" to indicate that the actual event 588 // time is to be spread. 589 span.Spread = true 590 rest = strings.Replace(rest, spreadToken, spanToken, 1) 591 } 592 593 if rest == "-" { 594 // whole day span 595 span.Start = Clock{0, 0} 596 span.End = Clock{24, 0} 597 } else if strings.Contains(rest, spanToken) { 598 span.Start, span.End, err = parseClockRange(rest) 599 } else { 600 span.Start, err = ParseClock(rest) 601 span.End = span.Start 602 } 603 604 if err != nil { 605 return ClockSpan{}, fmt.Errorf("cannot parse %q: not a valid time", s) 606 } 607 608 return span, nil 609 } 610 611 // parseWeekday parses a single weekday (eg. wed, mon5), 612 func parseWeekday(s string) (week Week, err error) { 613 l := len(s) 614 if l != 3 && l != 4 { 615 return week, fmt.Errorf("cannot parse %q: invalid format", s) 616 } 617 618 var day = s 619 var pos uint 620 if l == 4 { 621 day = s[0:3] 622 v, err := strconv.ParseUint(s[3:], 10, 32) 623 if err != nil || v < 1 || v > 5 { 624 return week, fmt.Errorf("cannot parse %q: invalid week number", s) 625 } 626 pos = uint(v) 627 } 628 629 weekday, ok := weekdayMap[day] 630 if !ok { 631 return week, fmt.Errorf("cannot parse %q: invalid weekday", s) 632 } 633 634 return Week{weekday, pos}, nil 635 } 636 637 // parseCount will parse the string containing a count token and return the 638 // count count and the rest of the string with count information removed, or an error. 639 func parseCount(s string) (count uint, rest string, err error) { 640 if !strings.Contains(s, countToken) { 641 return 0, s, nil 642 } 643 644 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 645 split := strings.Split(s, countToken) 646 if len(split) != 2 { 647 return 0, "", fmt.Errorf("cannot parse %q: invalid event count", s) 648 } 649 650 rest = split[0] 651 countStr := split[1] 652 c, err := strconv.ParseUint(countStr, 10, 32) 653 if err != nil || c == 0 { 654 return 0, "", fmt.Errorf("cannot parse %q: invalid event interval", s) 655 } 656 return uint(c), rest, nil 657 } 658 659 const ( 660 spanToken = "-" 661 spreadToken = "~" 662 countToken = "/" 663 ) 664 665 // Parse event set into a Schedule 666 func parseEventSet(s string) (*Schedule, error) { 667 var fragments []string 668 // split eventset into fragments 669 // eventset = wdaylist / timelist / wdaylist "," timelist 670 // or wdaysets 671 // wdaylist = wdayset *( "," wdayset ) 672 // or timesets 673 // timelist = timeset *( "," timeset ) 674 // 675 // NOTE: the syntax is ambiguous in the sense the type of a 'set' is now 676 // explicitly indicated, fragments with : inside are expected to be 677 // timesets 678 679 if els := strings.Split(s, ","); len(els) > 1 { 680 fragments = els 681 } else { 682 fragments = []string{s} 683 } 684 685 var schedule Schedule 686 // indicates that any further fragment must be timesets 687 var expectTime bool 688 689 for _, fragment := range fragments { 690 if len(fragment) == 0 { 691 return nil, fmt.Errorf("cannot parse %q: not a valid fragment", s) 692 } 693 694 if strings.Contains(fragment, ":") { 695 // must be a clock span 696 span, err := parseClockSpan(fragment) 697 if err != nil { 698 return nil, err 699 } 700 schedule.ClockSpans = append(schedule.ClockSpans, span) 701 702 expectTime = true 703 704 } else if !expectTime { 705 // we're not expecting timeset , so this must be a wdayset 706 span, err := parseWeekSpan(fragment) 707 if err != nil { 708 return nil, err 709 } 710 schedule.WeekSpans = append(schedule.WeekSpans, span) 711 } else { 712 // not a timeset 713 return nil, fmt.Errorf("cannot parse %q: invalid schedule fragment", fragment) 714 } 715 } 716 717 return &schedule, nil 718 } 719 720 // Includes checks whether given time t falls inside the time range covered by 721 // the schedule. A single time schedule eg. '10:00' is treated as spanning the 722 // time [10:00, 10:01) 723 func (sched *Schedule) Includes(t time.Time) bool { 724 if len(sched.WeekSpans) > 0 { 725 var weekMatch bool 726 for _, week := range sched.WeekSpans { 727 if week.Match(t) { 728 weekMatch = true 729 break 730 } 731 } 732 if !weekMatch { 733 return false 734 } 735 } 736 737 for _, tspan := range sched.flattenedClockSpans() { 738 window := tspan.Window(t) 739 if window.End.Equal(window.Start) { 740 // schedule granularity is a minute, a schedule '10:00' 741 // in fact is: [10:00, 10:01) 742 window.End = window.End.Add(time.Minute) 743 } 744 // Includes() does the [start,end] check, but we really what 745 // [start,end) 746 if window.Includes(t) && t.Before(window.End) { 747 return true 748 } 749 } 750 return false 751 } 752 753 // Includes checks whether given time t falls inside the time range covered by 754 // a schedule. 755 func Includes(schedule []*Schedule, t time.Time) bool { 756 for _, sched := range schedule { 757 if sched.Includes(t) { 758 return true 759 } 760 } 761 return false 762 }