github.com/anonymouse64/snapd@v0.0.0-20210824153203-04c4c42d842d/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 "regexp" 26 "strconv" 27 "strings" 28 "time" 29 30 "github.com/snapcore/snapd/randutil" 31 ) 32 33 // Match 0:00-24:00, where 24:00 means the later end of the day. 34 var validTime = regexp.MustCompile(`^([0-9]|0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])$|^24:00$`) 35 36 // Clock represents a hour:minute time within a day. 37 type Clock struct { 38 Hour int 39 Minute int 40 } 41 42 func (t Clock) String() string { 43 return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) 44 } 45 46 // Sub returns the duration t - other. 47 func (t Clock) Sub(other Clock) time.Duration { 48 t1 := time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute 49 t2 := time.Duration(other.Hour)*time.Hour + time.Duration(other.Minute)*time.Minute 50 dur := t1 - t2 51 if dur < 0 { 52 dur = -(dur + 24*time.Hour) 53 } 54 return dur 55 } 56 57 // Add adds given duration to t and returns a new Clock 58 func (t Clock) Add(dur time.Duration) Clock { 59 t1 := time.Duration(t.Hour)*time.Hour + time.Duration(t.Minute)*time.Minute 60 t2 := t1 + dur 61 nt := Clock{ 62 Hour: int(t2.Hours()) % 24, 63 Minute: int(t2.Minutes()) % 60, 64 } 65 return nt 66 } 67 68 // Time generates a time.Time with hour and minute set from t, while year, month 69 // and day are taken from base 70 func (t Clock) Time(base time.Time) time.Time { 71 return time.Date(base.Year(), base.Month(), base.Day(), 72 t.Hour, t.Minute, 0, 0, base.Location()) 73 } 74 75 // ParseClock parses a string that contains hour:minute and returns 76 // a Clock type or an error 77 func ParseClock(s string) (t Clock, err error) { 78 m := validTime.FindStringSubmatch(s) 79 if len(m) == 0 { 80 return t, fmt.Errorf("cannot parse %q", s) 81 } 82 83 if m[0] == "24:00" { 84 t.Hour = 24 85 return t, nil 86 } 87 88 t.Hour, err = strconv.Atoi(m[1]) 89 if err != nil { 90 return t, fmt.Errorf("cannot parse %q: %s", m[1], err) 91 } 92 t.Minute, err = strconv.Atoi(m[2]) 93 if err != nil { 94 return t, fmt.Errorf("cannot parse %q: %s", m[2], err) 95 } 96 return t, nil 97 } 98 99 const ( 100 EveryWeek uint = 0 101 LastWeek uint = 5 102 ) 103 104 // Week represents a weekday such as Monday, Tuesday, with optional 105 // week-in-the-month position, eg. the first Monday of the month 106 type Week struct { 107 Weekday time.Weekday 108 // Pos defines which week inside the month the Day refers to, where zero 109 // means every week, 1 means first occurrence of the weekday, and 5 110 // means last occurrence (which might be the fourth or the fifth). 111 Pos uint 112 } 113 114 func (w Week) String() string { 115 // Wednesday -> wed 116 day := strings.ToLower(w.Weekday.String()[0:3]) 117 if w.Pos == EveryWeek { 118 return day 119 } 120 return day + strconv.Itoa(int(w.Pos)) 121 } 122 123 // WeekSpan represents a span of weekdays between Start and End days, which may 124 // be a single day. WeekSpan may wrap around the week, eg. fri-mon is a span 125 // from Friday to Monday, mon1-fri is a span from the first Monday to the 126 // following Friday, while mon1 (internally, an equal start and end range) 127 // represents the 1st Monday of a month. 128 type WeekSpan struct { 129 Start Week 130 End Week 131 } 132 133 func (ws WeekSpan) String() string { 134 if ws.End != ws.Start { 135 return ws.Start.String() + "-" + ws.End.String() 136 } 137 return ws.Start.String() 138 } 139 140 // findNthWeekDay finds the nth occurrence of a given weekday in the month of t 141 func findNthWeekDay(t time.Time, weekday time.Weekday, nthInMonth uint) time.Time { 142 // move to the beginning of the month 143 t = t.AddDate(0, 0, -t.Day()+1) 144 145 var nth uint 146 for { 147 if t.Weekday() == weekday { 148 nth++ 149 if nth == nthInMonth { 150 break 151 } 152 } 153 t = t.Add(24 * time.Hour) 154 } 155 return t 156 } 157 158 // findLastWeekDay finds the last occurrence of a given weekday in the month of t 159 func findLastWeekDay(t time.Time, weekday time.Weekday) time.Time { 160 n := monthNext(t).Add(-24 * time.Hour) 161 for n.Weekday() != weekday { 162 n = n.Add(-24 * time.Hour) 163 } 164 return n 165 } 166 167 // matchingWeekdaysInMonth returns the number of occurrences of the weekday of t since 168 // the start of the month until t event 169 func matchingWeekdaysInMonth(t time.Time) int { 170 month := t.Month() 171 nth := 0 172 for n := t; n.Month() == month; n = n.Add(-7 * 24 * time.Hour) { 173 nth++ 174 } 175 return nth 176 } 177 178 // Match checks if t is within the day span represented by ws. 179 func (ws WeekSpan) Match(t time.Time) bool { 180 start, end := ws.Start, ws.End 181 wdStart, wdEnd := start.Weekday, end.Weekday 182 183 weekdayMatch := func(t time.Time) bool { 184 if wdStart <= wdEnd { 185 // single day (mon) or start < end (eg. mon-fri) 186 return t.Weekday() >= wdStart && t.Weekday() <= wdEnd 187 } 188 // wraps around the week end, eg. fri-mon 189 return t.Weekday() >= wdStart || t.Weekday() <= wdEnd 190 } 191 192 if start.Pos == EveryWeek && end.Pos == EveryWeek { 193 // generic weekday match, eg. mon-fri 194 return weekdayMatch(t) 195 } 196 197 // things that use a numbered weekday 198 199 // fun cases, eg (consider the calendar below): 200 // 201 // - mon1-fri, week span, start anchored at 1st Monday 06.08, matches: 202 // 06.08-10.08 203 // - mon-fri2, week span, end anchored at 2nd Friday 10.08, matches: 204 // 06.08-10.08 205 // - fri1-mon, week span, start anchored at 1st Friday 3.08, matches 206 // 03.08-06.08 207 // - mon-fri1, week span, end anchored at 1st Friday 3.08, matches 208 // 30.07-03.08, (crossing the month boundary) 209 // - fri4-thu, week span, end anchored at 4th Friday 27.07, matches 210 // 27.07-02.08, (crossing the month boundary), but also 24.08-30.08, 211 // which is within a single month 212 // 213 // July 2018 August 2018 214 // Su Mo Tu We Th Fr Sa Su Mo Tu We Th Fr Sa 215 // 1 2 3 4 5 6 7 1 2 3 4 216 // 8 9 10 11 12 13 14 5 6 7 8 9 10 11 217 // 15 16 17 18 19 20 21 12 13 14 15 16 17 18 218 // 22 23 24 25 26 27 28 19 20 21 22 23 24 25 219 // 29 30 31 26 27 28 29 30 31 220 221 // find out the range of week span, anchor sharing the same month as t 222 startDay, endDay := ws.dateRangeAnchoredAt(t) 223 anchoredAtStart := ws.AnchoredAtStart() 224 225 if t.After(endDay) || t.Before(startDay) { 226 // outside of dates range of the week span, consider edge cases: 227 // - next month if the span is anchored at the end (eg. mon-fri1 30.07-03.08, t=31.07) 228 // - previous month if the span is anchored at the start (eg. fri4-thu 27.07-02.08, t=01.08) 229 230 if anchoredAtStart { 231 // eg. fri4-thu, range anchored at previous month 232 if matchingWeekdaysInMonth(t) != 1 { 233 // no match if t is not within the first week 234 return false 235 } 236 prevMonth := monthPrev(t) 237 startDay, endDay = ws.dateRangeAnchoredAt(prevMonth) 238 } else { 239 // eg. mon-fri1, range anchored at the next month 240 if !isLastWeekdayInMonth(t) { 241 // no match if t is not within the last week 242 return false 243 } 244 nextMonth := monthNext(t) 245 startDay, endDay = ws.dateRangeAnchoredAt(nextMonth) 246 } 247 // at this point we will check whether t matches the range that 248 // spills from the previous month or from the next month 249 } 250 outside := t.Before(startDay) || t.After(endDay) 251 return !outside 252 } 253 254 // monthNext returns the first day of the next month relative to t 255 func monthNext(t time.Time) time.Time { 256 n := t 257 // advance by 28 days at most, so that we don't skip a 28 day February 258 n = n.AddDate(0, 0, 28) 259 for n.Month() == t.Month() { 260 n = n.Add(24 * time.Hour) 261 } 262 if n.Day() != 1 { 263 // backtrack if we didn't land on the first day yet 264 n = n.AddDate(0, 0, -n.Day()+1) 265 } 266 return n 267 } 268 269 // monthPrev returns the last day of previous month relative to t 270 func monthPrev(t time.Time) time.Time { 271 return t.AddDate(0, 0, -1*(t.Day()+1)) 272 } 273 274 // AnchoredAtStart returns true when the week span is anchored at the starting 275 // point, or false otherwise 276 func (ws WeekSpan) AnchoredAtStart() bool { 277 return ws.Start.Pos != EveryWeek 278 } 279 280 // dateRangeAnchoredAt returns the range of dates that match the week span, with the 281 // anchor sharing the same month as t 282 func (ws WeekSpan) dateRangeAnchoredAt(t time.Time) (start, end time.Time) { 283 weekPos := ws.End.Pos 284 anchoredAtStart := ws.AnchoredAtStart() 285 if anchoredAtStart { 286 weekPos = ws.Start.Pos 287 } 288 // first check the start/end dates in the same month as t 289 if weekPos != LastWeek { 290 start = findNthWeekDay(t, ws.Start.Weekday, weekPos) 291 end = findNthWeekDay(t, ws.End.Weekday, weekPos) 292 } else { 293 start = findLastWeekDay(t, ws.Start.Weekday) 294 end = findLastWeekDay(t, ws.End.Weekday) 295 } 296 297 // eg. mon1-mon span falls under the Equal && !singleDay case 298 if start.After(end) || (start.Equal(end) && !ws.IsSingleDay()) { 299 if anchoredAtStart { 300 end = end.Add(7 * 24 * time.Hour) 301 } else { 302 start = start.Add(-7 * 24 * time.Hour) 303 } 304 } 305 return start, end 306 } 307 308 // IsSingleDay returns true when the week span represents a single day 309 func (ws WeekSpan) IsSingleDay() bool { 310 return ws.Start == ws.End 311 } 312 313 // ClockSpan represents a time span within 24h, potentially crossing days. For 314 // example, 23:00-1:00 represents a span from 11pm to 1am. 315 type ClockSpan struct { 316 Start Clock 317 End Clock 318 // Split defines the number of subspans this span will be divided into. 319 Split uint 320 // Spread defines whether the events are randomly spread inside the span 321 // or subspans. 322 Spread bool 323 } 324 325 func (ts ClockSpan) String() string { 326 sep := "-" 327 if ts.Spread { 328 sep = "~" 329 } 330 if ts.End != ts.Start { 331 s := ts.Start.String() + sep + ts.End.String() 332 if ts.Split > 0 { 333 s += "/" + strconv.Itoa(int(ts.Split)) 334 } 335 return s 336 } 337 return ts.Start.String() 338 } 339 340 // Window generates a ScheduleWindow which has the start date same as t. The 341 // window's start and end time are set according to Start and End, with the end 342 // time possibly crossing into the next day. 343 func (ts ClockSpan) Window(t time.Time) ScheduleWindow { 344 start := ts.Start.Time(t) 345 end := ts.End.Time(t) 346 347 // 23:00-1:00 348 if end.Before(start) { 349 end = end.Add(24 * time.Hour) 350 } 351 return ScheduleWindow{ 352 Start: start, 353 End: end, 354 Spread: ts.Spread, 355 } 356 } 357 358 // ClockSpans returns a slice of ClockSpans generated from ts by splitting the 359 // time between ts.Start and ts.End into ts.Split equal spans. 360 func (ts ClockSpan) ClockSpans() []ClockSpan { 361 if ts.Split == 0 || ts.Split == 1 || ts.End == ts.Start { 362 return []ClockSpan{ts} 363 } 364 365 span := ts.End.Sub(ts.Start) 366 if span < 0 { 367 span = -span 368 } 369 step := span / time.Duration(ts.Split) 370 371 spans := make([]ClockSpan, ts.Split) 372 for i := uint(0); i < ts.Split; i++ { 373 start := ts.Start.Add(time.Duration(i) * step) 374 spans[i] = ClockSpan{ 375 Start: start, 376 End: start.Add(step), 377 Split: 0, // no more subspans 378 Spread: ts.Spread, 379 } 380 } 381 return spans 382 } 383 384 // Schedule represents a single schedule 385 type Schedule struct { 386 WeekSpans []WeekSpan 387 ClockSpans []ClockSpan 388 } 389 390 func (sched *Schedule) String() string { 391 var buf bytes.Buffer 392 393 for i, span := range sched.WeekSpans { 394 if i > 0 { 395 buf.WriteByte(',') 396 } 397 buf.WriteString(span.String()) 398 } 399 400 if len(sched.WeekSpans) > 0 && len(sched.ClockSpans) > 0 { 401 buf.WriteByte(',') 402 } 403 404 for i, span := range sched.ClockSpans { 405 if i > 0 { 406 buf.WriteByte(',') 407 } 408 buf.WriteString(span.String()) 409 } 410 return buf.String() 411 } 412 413 func (sched *Schedule) flattenedClockSpans() []ClockSpan { 414 baseTimes := sched.ClockSpans 415 if len(baseTimes) == 0 { 416 baseTimes = []ClockSpan{{}} 417 } 418 419 times := make([]ClockSpan, 0, len(baseTimes)) 420 for _, ts := range baseTimes { 421 times = append(times, ts.ClockSpans()...) 422 } 423 return times 424 } 425 426 // isLastWeekdayInMonth returns true if t.Weekday() is the last weekday 427 // occurring this t.Month(), eg. check is Feb 25 2017 is the last Saturday of 428 // February. 429 func isLastWeekdayInMonth(t time.Time) bool { 430 // try a week from now, if it's still the same month then t.Weekday() is 431 // not last 432 return t.Month() != t.Add(7*24*time.Hour).Month() 433 } 434 435 // ScheduleWindow represents a time window between Start and End times when the 436 // scheduled event can happen. 437 type ScheduleWindow struct { 438 Start time.Time 439 End time.Time 440 // Spread defines whether the event shall be randomly placed between 441 // Start and End times 442 Spread bool 443 } 444 445 // Includes returns whether t is inside the window. 446 func (s ScheduleWindow) Includes(t time.Time) bool { 447 return !(t.Before(s.Start) || t.After(s.End)) 448 } 449 450 // IsZero returns whether s is uninitialized. 451 func (s ScheduleWindow) IsZero() bool { 452 return s.Start.IsZero() || s.End.IsZero() 453 } 454 455 // Next returns the earliest window after last according to the schedule. 456 func (sched *Schedule) Next(last time.Time) ScheduleWindow { 457 now := timeNow() 458 459 tspans := sched.flattenedClockSpans() 460 461 for t := last; ; t = t.Add(24 * time.Hour) { 462 // try to find a matching schedule by moving in 24h jumps, check 463 // if the event needs to happen on a specific day in a specific 464 // week, next pick the earliest event time 465 466 var window ScheduleWindow 467 468 if len(sched.WeekSpans) > 0 { 469 // if there's a week schedule, check if we hit that 470 // first 471 var weekMatch bool 472 for _, week := range sched.WeekSpans { 473 if week.Match(t) { 474 weekMatch = true 475 break 476 } 477 } 478 479 if !weekMatch { 480 continue 481 } 482 } 483 484 for _, tspan := range tspans { 485 // consider all time spans for this particular date and 486 // find the earliest possible one that is not before 487 // 'now', and does not include the 'last' time 488 newWindow := tspan.Window(t) 489 490 if newWindow.End.Before(now) { 491 // the time span ends before 'now', try another 492 // one 493 continue 494 } 495 496 if newWindow.Includes(last) { 497 // same interval as last update, move forward 498 continue 499 } 500 501 if window.IsZero() || newWindow.Start.Before(window.Start) { 502 // this candidate comes before current 503 // candidate, so use it 504 window = newWindow 505 } 506 } 507 if window.End.Before(now) { 508 // no suitable time span was found this day so try the 509 // next day 510 continue 511 } 512 return window 513 } 514 515 } 516 517 func randDur(a, b time.Time) time.Duration { 518 dur := b.Sub(a) 519 if dur > 5*time.Minute { 520 // doing it this way we still spread really small windows about 521 dur -= 5 * time.Minute 522 } 523 524 if dur <= 0 { 525 // avoid panic'ing (even if things are probably messed up) 526 return 0 527 } 528 529 return randutil.RandomDuration(dur) 530 } 531 532 var ( 533 timeNow = time.Now 534 ) 535 536 // Next returns the earliest event after last according to the provided 537 // schedule but no later than maxDuration since last. 538 func Next(schedule []*Schedule, last time.Time, maxDuration time.Duration) time.Duration { 539 now := timeNow() 540 541 window := ScheduleWindow{ 542 Start: last.Add(maxDuration), 543 End: last.Add(maxDuration).Add(1 * time.Hour), 544 } 545 546 for _, sched := range schedule { 547 next := sched.Next(last) 548 if next.Start.Before(window.Start) { 549 window = next 550 } 551 } 552 if window.Start.Before(now) { 553 return 0 554 } 555 556 when := window.Start.Sub(now) 557 if window.Spread { 558 when += randDur(window.Start, window.End) 559 } 560 561 return when 562 563 } 564 565 var weekdayMap = map[string]time.Weekday{ 566 "sun": time.Sunday, 567 "mon": time.Monday, 568 "tue": time.Tuesday, 569 "wed": time.Wednesday, 570 "thu": time.Thursday, 571 "fri": time.Friday, 572 "sat": time.Saturday, 573 } 574 575 // parseClockRange parses a string like "9:00-11:00" and returns the start and 576 // end times. 577 func parseClockRange(s string) (start, end Clock, err error) { 578 l := strings.SplitN(s, "-", 2) 579 if len(l) != 2 { 580 return start, end, fmt.Errorf("cannot parse %q: not a valid interval", s) 581 } 582 583 start, err = ParseClock(l[0]) 584 if err != nil { 585 return start, end, fmt.Errorf("cannot parse %q: not a valid time", l[0]) 586 } 587 end, err = ParseClock(l[1]) 588 if err != nil { 589 return start, end, fmt.Errorf("cannot parse %q: not a valid time", l[1]) 590 } 591 592 return start, end, nil 593 } 594 595 // ParseLegacySchedule takes an obsolete schedule string in the form of: 596 // 597 // 9:00-15:00 (every day between 9am and 3pm) 598 // 9:00-15:00/21:00-22:00 (every day between 9am,5pm and 9pm,10pm) 599 // 600 // and returns a list of Schedule types or an error 601 func ParseLegacySchedule(scheduleSpec string) ([]*Schedule, error) { 602 var schedule []*Schedule 603 604 for _, s := range strings.Split(scheduleSpec, "/") { 605 start, end, err := parseClockRange(s) 606 if err != nil { 607 return nil, err 608 } 609 schedule = append(schedule, &Schedule{ 610 ClockSpans: []ClockSpan{{ 611 Start: start, 612 End: end, 613 Spread: true, 614 }}, 615 }) 616 } 617 618 return schedule, nil 619 } 620 621 // ParseSchedule parses a schedule in V2 format. The format is described as: 622 // 623 // eventlist = eventset *( ",," eventset ) 624 // eventset = wdaylist / timelist / wdaylist "," timelist 625 // 626 // wdaylist = wdayset *( "," wdayset ) 627 // wdayset = wday / wdaynumber / wdayspan 628 // wday = ( "sun" / "mon" / "tue" / "wed" / "thu" / "fri" / "sat" ) 629 // wdaynumber = ( "sun" / "mon" / "tue" / "wed" / "thu" / "fri" / "sat" ) DIGIT 630 // wdayspan = wday "-" wday / wdaynumber "-" wday / wday "-" wdaynumber 631 // 632 // timelist = timeset *( "," timeset ) 633 // timeset = time / timespan 634 // time = 2DIGIT ":" 2DIGIT 635 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 636 // count = 1*DIGIT 637 // 638 // Examples: 639 // mon,10:00,,fri,15:00 (Monday at 10:00, Friday at 15:00) 640 // mon,fri,10:00,15:00 (Monday at 10:00 and 15:00, Friday at 10:00 and 15:00) 641 // mon-wed,fri,9:00-11:00/2 (Monday to Wednesday and on Friday, twice between 642 // 9:00 and 11:00) 643 // mon,9:00~11:00,,wed,22:00~23:00 (Monday, sometime between 9:00 and 11:00, and 644 // on Wednesday, sometime between 22:00 and 23:00) 645 // mon,wed (Monday and on Wednesday) 646 // mon,,wed (same as above) 647 // mon1-wed (1st Monday of the month to the following Wednesday) 648 // mon-wed1 (from the 1st Wednesday of the month to the prior Monday) 649 // mon1 (1st Monday of the month) 650 // mon1-mon (from the 1st Monday of the month to the following Monday) 651 // 652 // Returns a slice of schedules or an error if parsing failed 653 func ParseSchedule(scheduleSpec string) ([]*Schedule, error) { 654 var schedule []*Schedule 655 656 for _, s := range strings.Split(scheduleSpec, ",,") { 657 // cut the schedule in event sets 658 // eventlist = eventset *( ",," eventset ) 659 sched, err := parseEventSet(s) 660 if err != nil { 661 return nil, err 662 } 663 schedule = append(schedule, sched) 664 } 665 return schedule, nil 666 } 667 668 // parseWeekSpan parses a weekly span such as "mon-tue" or "mon2-tue3". 669 func parseWeekSpan(s string) (span WeekSpan, err error) { 670 var parsed WeekSpan 671 672 split := strings.Split(s, spanToken) 673 if len(split) > 2 { 674 return span, fmt.Errorf("cannot parse %q: invalid week span", s) 675 } 676 677 parsed.Start, err = parseWeekday(split[0]) 678 if err != nil { 679 return span, fmt.Errorf("cannot parse %q: %q is not a valid weekday", s, split[0]) 680 } 681 682 if len(split) == 2 { 683 parsed.End, err = parseWeekday(split[1]) 684 if err != nil { 685 return span, fmt.Errorf("cannot parse %q: %q is not a valid weekday", s, split[1]) 686 } 687 } else { 688 parsed.End = parsed.Start 689 } 690 691 if (parsed.Start.Pos != EveryWeek) && (parsed.End.Pos != EveryWeek) { 692 // both ends have a week position set 693 694 if parsed.End.Pos < parsed.Start.Pos { 695 // eg. mon4-mon1 696 return span, fmt.Errorf("cannot parse %q: unsupported schedule", s) 697 } 698 699 if !parsed.IsSingleDay() { 700 // ambiguous case that produces different schedules depending on 701 // the calendar, to avoid the ambiguity, anchor the schedule at 702 // the start of the week span, eg. mon1-tue2 -> mon1-tue 703 // 704 // TODO: error out instead of degrading when a 705 // deprecated span is used under the new rules 706 parsed.End.Pos = EveryWeek 707 } 708 } 709 710 return parsed, nil 711 } 712 713 // parseClockSpan parses a time specification which can either be `<hh>:<mm>` or 714 // `<hh>:<mm>[-~]<hh>:<mm>[/count]`. Alternatively the span can be one of 715 // special tokens `-`, `~` (followed by an optional [/count]) that indicate a 716 // whole day span, or a whole day span with spread respectively. 717 func parseClockSpan(s string) (span ClockSpan, err error) { 718 var rest string 719 720 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 721 722 span.Split, rest, err = parseCount(s) 723 if err != nil { 724 return ClockSpan{}, fmt.Errorf("cannot parse %q: not a valid interval", s) 725 } 726 727 if strings.Contains(rest, spreadToken) { 728 // timespan uses "~" to indicate that the actual event 729 // time is to be spread. 730 span.Spread = true 731 rest = strings.Replace(rest, spreadToken, spanToken, 1) 732 } 733 734 if rest == "-" { 735 // whole day span 736 span.Start = Clock{0, 0} 737 span.End = Clock{24, 0} 738 } else if strings.Contains(rest, spanToken) { 739 span.Start, span.End, err = parseClockRange(rest) 740 } else { 741 span.Start, err = ParseClock(rest) 742 span.End = span.Start 743 } 744 745 if err != nil { 746 return ClockSpan{}, fmt.Errorf("cannot parse %q: not a valid time", s) 747 } 748 749 return span, nil 750 } 751 752 // parseWeekday parses a single weekday (eg. wed, mon5), 753 func parseWeekday(s string) (week Week, err error) { 754 l := len(s) 755 if l != 3 && l != 4 { 756 return week, fmt.Errorf("cannot parse %q: invalid format", s) 757 } 758 759 var day = s 760 var pos uint 761 if l == 4 { 762 day = s[0:3] 763 v, err := strconv.ParseUint(s[3:], 10, 32) 764 if err != nil || v < 1 || v > 5 { 765 return week, fmt.Errorf("cannot parse %q: invalid week number", s) 766 } 767 pos = uint(v) 768 } 769 770 weekday, ok := weekdayMap[day] 771 if !ok { 772 return week, fmt.Errorf("cannot parse %q: invalid weekday", s) 773 } 774 775 return Week{weekday, pos}, nil 776 } 777 778 // parseCount will parse the string containing a count token and return the 779 // count count and the rest of the string with count information removed, or an error. 780 func parseCount(s string) (count uint, rest string, err error) { 781 if !strings.Contains(s, countToken) { 782 return 0, s, nil 783 } 784 785 // timespan = time ( "-" / "~" ) time [ "/" ( time / count ) ] 786 split := strings.Split(s, countToken) 787 if len(split) != 2 { 788 return 0, "", fmt.Errorf("cannot parse %q: invalid event count", s) 789 } 790 791 rest = split[0] 792 countStr := split[1] 793 c, err := strconv.ParseUint(countStr, 10, 32) 794 if err != nil || c == 0 { 795 return 0, "", fmt.Errorf("cannot parse %q: invalid event interval", s) 796 } 797 return uint(c), rest, nil 798 } 799 800 const ( 801 spanToken = "-" 802 spreadToken = "~" 803 countToken = "/" 804 ) 805 806 // Parse event set into a Schedule 807 func parseEventSet(s string) (*Schedule, error) { 808 var fragments []string 809 // split eventset into fragments 810 // eventset = wdaylist / timelist / wdaylist "," timelist 811 // or wdaysets 812 // wdaylist = wdayset *( "," wdayset ) 813 // or timesets 814 // timelist = timeset *( "," timeset ) 815 // 816 // NOTE: the syntax is ambiguous in the sense the type of a 'set' is now 817 // explicitly indicated, fragments with : inside are expected to be 818 // timesets 819 820 if els := strings.Split(s, ","); len(els) > 1 { 821 fragments = els 822 } else { 823 fragments = []string{s} 824 } 825 826 var schedule Schedule 827 // indicates that any further fragment must be timesets 828 var expectTime bool 829 830 for _, fragment := range fragments { 831 if len(fragment) == 0 { 832 return nil, fmt.Errorf("cannot parse %q: not a valid fragment", s) 833 } 834 835 if strings.Contains(fragment, ":") { 836 // must be a clock span 837 span, err := parseClockSpan(fragment) 838 if err != nil { 839 return nil, err 840 } 841 schedule.ClockSpans = append(schedule.ClockSpans, span) 842 843 expectTime = true 844 845 } else if !expectTime { 846 // we're not expecting timeset , so this must be a wdayset 847 span, err := parseWeekSpan(fragment) 848 if err != nil { 849 return nil, err 850 } 851 schedule.WeekSpans = append(schedule.WeekSpans, span) 852 } else { 853 // not a timeset 854 return nil, fmt.Errorf("cannot parse %q: invalid schedule fragment", fragment) 855 } 856 } 857 858 return &schedule, nil 859 } 860 861 // Includes checks whether given time t falls inside the time range covered by 862 // the schedule. A single time schedule eg. '10:00' is treated as spanning the 863 // time [10:00, 10:01) 864 func (sched *Schedule) Includes(t time.Time) bool { 865 if len(sched.WeekSpans) > 0 { 866 var weekMatch bool 867 for _, week := range sched.WeekSpans { 868 if week.Match(t) { 869 weekMatch = true 870 break 871 } 872 } 873 if !weekMatch { 874 return false 875 } 876 } 877 878 for _, tspan := range sched.flattenedClockSpans() { 879 window := tspan.Window(t) 880 if window.End.Equal(window.Start) { 881 // schedule granularity is a minute, a schedule '10:00' 882 // in fact is: [10:00, 10:01) 883 window.End = window.End.Add(time.Minute) 884 } 885 // Includes() does the [start,end] check, but we really what 886 // [start,end) 887 if window.Includes(t) && t.Before(window.End) { 888 return true 889 } 890 } 891 return false 892 } 893 894 // Includes checks whether given time t falls inside the time range covered by 895 // a schedule. 896 func Includes(schedule []*Schedule, t time.Time) bool { 897 for _, sched := range schedule { 898 if sched.Includes(t) { 899 return true 900 } 901 } 902 return false 903 }