github.com/chipaca/snappy@v0.0.0-20210104084008-1f06296fe8ad/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  }