// Package humaninterface provides a human-writable timespan description DSL. package humaninterface import ( "fmt" "regexp" "strconv" "strings" "time" ) type Timespan struct { T0, T1 time.Time } func (ts Timespan) Equal(other Timespan) bool { return ts.T0.Equal(other.T0) && ts.T1.Equal(other.T1) } func (ts Timespan) Includes(t time.Time) bool { if !ts.T0.IsZero() && t.Before(ts.T0) { return false } if !ts.T1.IsZero() && t.After(ts.T1) { return false } return true } func (ts Timespan) String() string { return fmt.Sprintf("%v -- %v", ts.T0, ts.T1) d := ts.T1.Sub(ts.T0) / 2 return fmt.Sprintf("%v ±%v", ts.T0.Add(d), d) } func Parse(now time.Time, timespandescription string) (Timespan, error) { if strings.Contains(timespandescription, "--") { td0td1 := strings.Split(timespandescription, "--") if len(td0td1) != 2 { return Timespan{}, fmt.Errorf("multiple \"--\": %q", timespandescription) } td0, err := parseTimespanEq(now, td0td1[0]) if err != nil { return Timespan{}, fmt.Errorf("left of \"--\": %v", err) } td1, err := parseTimespanEq(now, td0td1[1]) if err != nil { return Timespan{}, fmt.Errorf("right of \"--\": %v", err) } return Timespan{T0: td0.T0, T1: td1.T1}, nil } return parseTimespanEq(now, timespandescription) } // "~" means plus/minus func parseTimespanEq(now time.Time, timespandescription string) (Timespan, error) { timespandescription = strings.ToLower(timespandescription) type Verb struct { Op, Arg string } var verbs []Verb outer: for len(timespandescription) > 0 { for _, re := range []*regexp.Regexp { regexp.MustCompile(` *()([0-9][0-9-]+)`), regexp.MustCompile(` *([±~+-]?)([0-9a-z]+)`), } { loc := re.FindStringIndex(timespandescription) if len(loc) != 2 || loc[0] != 0 { continue } k := loc[1] smatchs := re.FindStringSubmatch(timespandescription[:k]) if len(smatchs) != 1+2 { panic("unreachable") } timespandescription = timespandescription[k:] verbs = append(verbs, Verb{Op: smatchs[1], Arg: smatchs[2]}) continue outer } return Timespan{}, fmt.Errorf("gibberish: %q", timespandescription) } // var ts Timespan if len(verbs) > 0 && verbs[0].Op == "" { var err error ts, err = parseTimespan(now, verbs[0].Arg) if err != nil { return Timespan{}, err } verbs = verbs[1:] } for _, verb := range verbs { switch verb.Op { default: return Timespan{}, fmt.Errorf("unrecognised verb: %v", verb) case "": return Timespan{}, fmt.Errorf("non-first empty op: %v", verb) case "+": d, err := parseDuration(verb.Arg) if err != nil { return Timespan{}, err } ts.T0 = ts.T0.Add(+d) ts.T1 = ts.T1.Add(+d) case "-": d, err := parseDuration(verb.Arg) if err != nil { return Timespan{}, err } ts.T0 = ts.T0.Add(-d) ts.T1 = ts.T1.Add(-d) case "±", "~": d, err := parseDuration(verb.Arg) if err != nil { return Timespan{}, err } ts.T0 = ts.T0.Add(-d) ts.T1 = ts.T1.Add(+d) } } return ts, nil } func MakeTimespan(t0, t1 time.Time) Timespan { return Timespan{T0: t0, T1: t1} } func parseTimespan(now time.Time, description string) (Timespan, error) { description = strings.ToLower(description) day := func(daydelta int) Timespan { t0 := time.Date(now.Year(), now.Month(), now.Day()+daydelta, 0, 0, 0, 0, now.Location()) t1 := time.Date(now.Year(), now.Month(), now.Day()+daydelta, 24, 0, 0, 0, now.Location()) return MakeTimespan(t0, t1) } week := func(t time.Time) Timespan { t0 := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) for t0.Weekday() != time.Monday { t0 = t0.AddDate(0, 0, -1) } return MakeTimespan(t0, t0.AddDate(0, 0, 7)) } month := func(t time.Time) Timespan { t0 := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) t1 := time.Date(t.Year(), t.Month(), 31, 24, 0, 0, 0, t.Location()) return MakeTimespan(t0, t1) } year := func(t time.Time) Timespan { t0 := time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, t.Location()) t1 := time.Date(t.Year(), time.December, 31, 24, 0, 0, 0, t.Location()) return MakeTimespan(t0, t1) } switch description { case "now": return MakeTimespan(now, now), nil case "today": return day(0), nil case "yesterday": return day(-1), nil case "tomorrow": return day(+1), nil case "lastweek": ts := week(week(now).T0.AddDate(0, 0, -1)) return ts, nil case "thisweek": return week(now), nil case "nextweek": return week(week(now).T1.AddDate(0, 0, +1)), nil case "lastmonth": return month(month(now).T0.AddDate(0, 0, -1)), nil case "thismonth": return month(now), nil case "nextmonth": return month(month(now).T1.AddDate(0, 0, +1)), nil case "lastyear": return year(year(now).T0.AddDate(0, 0, -1)), nil case "thisyear": return year(now), nil case "nextyear": return year(year(now).T1.AddDate(0, 0, +1)), nil case "never", "notime": return Timespan{T1: time.Time{}.Add(-time.Second)}, nil case "always", "anytime": return Timespan{}, nil } // if ints := reints(regexp.MustCompile(`^([0-9]{4})$`), description); len(ints) == 1 { t0 := time.Date(ints[0], time.January, 1, 0, 0, 0, 0, now.Location()) return MakeTimespan(t0, t0.AddDate(1, 0, 0)), nil } else if ints := reints(regexp.MustCompile(`^([0-9]{4})-([0-9][0-9]?)$`), description); len(ints) == 2 { t0 := time.Date(ints[0], time.Month(ints[1]), 1, 0, 0, 0, 0, now.Location()) return MakeTimespan(t0, t0.AddDate(0, 1, 0)), nil } else if ints := reints(regexp.MustCompile(`^([0-9]{4})-([0-9][0-9]?)-([0-9][0-9]?)$`), description); len(ints) == 3 { t0 := time.Date(ints[0], time.Month(ints[1]), ints[2], 0, 0, 0, 0, now.Location()) return MakeTimespan(t0, t0.AddDate(0, 0, 1)), nil } return Timespan{}, fmt.Errorf("unrecognised timespan description: %q", description) } func parseDuration(description string) (time.Duration, error) { description = strings.ToLower(description) // TODO pedantically, these durations MAY be interpreted to depend on a point in time if ints := reints(regexp.MustCompile(`^([0-9]+)y(?:ear)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * 356*31*24*time.Hour, nil } else if ints := reints(regexp.MustCompile(`^([0-9]+)month$`), description); len(ints) == 1 { return time.Duration(ints[0]) * 31*24*time.Hour, nil } else if ints := reints(regexp.MustCompile(`^([0-9]+)w(?:eek)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * 7*24*time.Hour, nil } else if ints := reints(regexp.MustCompile(`^([0-9]+)d(?:ay)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * 24*time.Hour, nil } if ints := reints(regexp.MustCompile(`^([0-9]+)h(?:r)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * time.Hour, nil } else if ints := reints(regexp.MustCompile(`^([0-9]+)m(?:in)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * time.Minute, nil } else if ints := reints(regexp.MustCompile(`^([0-9]+)s(?:ec)?$`), description); len(ints) == 1 { return time.Duration(ints[0]) * time.Second, nil } return 0, fmt.Errorf("unrecognised duration description: %q", description) } func reints(re *regexp.Regexp, raw string) []int { var ints []int smatchs := re.FindStringSubmatch(raw) if len(smatchs) < 1 { return nil } for _, smatch := range smatchs[1:] { i, err := strconv.ParseInt(smatch, 10, 64) if err != nil || int64(int(i)) != i { return nil } ints = append(ints, int(i)) } return ints }