You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
aerc/lib/parse/daterange.go

472 lines
9.2 KiB
Go

package parse
import (
"fmt"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/lib/log"
)
const dateFmt = "2006-01-02"
// ParseDateRange parses a date range into a start and end date. Dates are
// expected to be in the YYYY-MM-DD format.
//
// Start and end dates are connected by the range operator ".." where end date
// is not included in the date range.
//
// ParseDateRange can also parse open-ended ranges, i.e. start.. or ..end are
// allowed.
//
// Relative date terms (such as "1 week 1 day" or "1w 1d") can be used, too.
func DateRange(s string) (start, end time.Time, err error) {
s = cleanInput(s)
s = ensureRangeOp(s)
i := strings.Index(s, "..")
switch {
case i < 0:
// single date
start, err = translate(s)
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
end = start.AddDate(0, 0, 1)
case i == 0:
// end date only
if len(s) < 2 {
err = fmt.Errorf("no date found")
return
}
end, err = translate(s[2:])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
case i > 0:
// start date first
start, err = translate(s[:i])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
if len(s[i:]) <= 2 {
return
}
// and end dates if available
end, err = translate(s[(i + 2):])
if err != nil {
err = fmt.Errorf("failed to parse date: %w", err)
return
}
}
return
}
type dictFunc = func(bool) time.Time
// dict is a dictionary to translate words to dates. Map key must be at least 3
// characters for matching purposes.
var dict map[string]dictFunc = map[string]dictFunc{
"today": func(_ bool) time.Time {
return time.Now()
},
"yesterday": func(_ bool) time.Time {
return time.Now().AddDate(0, 0, -1)
},
"week": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Monday)+diff)
},
"month": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(0, diff, -t.Day()+1)
},
"year": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff, 0, -t.YearDay()+1)
},
"monday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Monday)+diff)
},
"tuesday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Tuesday)+diff)
},
"wednesday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Wednesday)+diff)
},
"thursday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Thursday)+diff)
},
"friday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Friday)+diff)
},
"saturday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Saturday)+diff)
},
"sunday": func(this bool) time.Time {
diff := 0
if !this {
diff = -7
}
return time.Now().AddDate(0, 0,
daydiff(time.Sunday)+diff)
},
"january": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.January), -t.Day()+1)
},
"february": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.February), -t.Day()+1)
},
"march": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.March), -t.Day()+1)
},
"april": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.April), -t.Day()+1)
},
"may": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.May), -t.Day()+1)
},
"june": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.June), -t.Day()+1)
},
"july": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.July), -t.Day()+1)
},
"august": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.August), -t.Day()+1)
},
"september": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.September), -t.Day()+1)
},
"october": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.October), -t.Day()+1)
},
"november": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.November), -t.Day()+1)
},
"december": func(this bool) time.Time {
diff := 0
if !this {
diff = -1
}
t := time.Now()
return t.AddDate(diff,
monthdiff(time.December), -t.Day()+1)
},
}
func daydiff(d time.Weekday) int {
daydiff := d - time.Now().Weekday()
if daydiff > 0 {
return int(daydiff) - 7
}
return int(daydiff)
}
func monthdiff(d time.Month) int {
monthdiff := d - time.Now().Month()
if monthdiff > 0 {
return int(monthdiff) - 12
}
return int(monthdiff)
}
// translate translates regular time words into date strings
func translate(s string) (time.Time, error) {
if s == "" {
return time.Now(), fmt.Errorf("empty string")
}
log.Tracef("input: %s", s)
s0 := s
// if next characters is integer, then parse a relative date
if '0' <= s[0] && s[0] <= '9' && hasUnit(s) {
relDate, err := RelativeDate(s)
if err != nil {
log.Errorf("could not parse relative date from '%s': %v",
s0, err)
} else {
log.Tracef("relative date: translated to %v from %s",
relDate, s0)
return bod(relDate.Apply(time.Now())), nil
}
}
// consult dictionary for terms translation
s, this, hasPrefix := handlePrefix(s)
for term, dateFn := range dict {
if term == "month" && !hasPrefix {
continue
}
if strings.Contains(term, s) {
log.Tracef("dictionary: translated to %s from %s",
term, s0)
return bod(dateFn(this)), nil
}
}
// this is a regular date, parse it in the normal format
log.Infof("parse: translates %s to regular format", s0)
return time.Parse(dateFmt, s)
}
// bod returns the begin of the day
func bod(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
func handlePrefix(s string) (string, bool, bool) {
var hasPrefix bool
this := true
if strings.HasPrefix(s, "this") {
hasPrefix = true
s = strings.TrimPrefix(s, "this")
}
if strings.HasPrefix(s, "last") {
hasPrefix = true
this = false
s = strings.TrimPrefix(s, "last")
}
return s, this, hasPrefix
}
func cleanInput(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "_", "")
return s
}
// RelDate is the relative date in the past, e.g. yesterday would be
// represented as RelDate{0,0,1}.
type RelDate struct {
Year uint
Month uint
Day uint
}
func (d RelDate) Apply(t time.Time) time.Time {
return t.AddDate(-int(d.Year), -int(d.Month), -int(d.Day))
}
// ParseRelativeDate parses a string of relative terms into a DateAdd.
//
// Syntax: N (year|month|week|day) ..
//
// The following are valid inputs:
// 5weeks1day
// 5w1d
//
// Adapted from the Go stdlib in src/time/format.go
func RelativeDate(s string) (RelDate, error) {
s0 := s
s = cleanInput(s)
var da RelDate
for s != "" {
var n uint
var err error
// expect an integer
if !('0' <= s[0] && s[0] <= '9') {
return da, fmt.Errorf("not a valid relative term: %s",
s0)
}
// consume integer
n, s, err = leadingInt(s)
if err != nil {
return da, fmt.Errorf("cannot read integer in %s",
s0)
}
// consume the units
i := 0
for ; i < len(s); i++ {
c := s[i]
if '0' <= c && c <= '9' {
break
}
}
if i == 0 {
return da, fmt.Errorf("missing unit in %s", s0)
}
u := s[:i]
s = s[i:]
switch u[0] {
case 'y':
da.Year += n
case 'm':
da.Month += n
case 'w':
da.Day += 7 * n
case 'd':
da.Day += n
default:
return da, fmt.Errorf("unknown unit %s in %s", u, s0)
}
}
return da, nil
}
func hasUnit(s string) (has bool) {
for _, u := range "ymwd" {
if strings.Contains(s, string(u)) {
return true
}
}
return false
}
// leadingInt parses and returns the leading integer in s.
//
// Adapted from the Go stdlib in src/time/format.go
func leadingInt(s string) (x uint, rem string, err error) {
i := 0
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}
x = x*10 + uint(c) - '0'
}
return x, s[i:], nil
}
func ensureRangeOp(s string) string {
if strings.Contains(s, "..") {
return s
}
s0 := s
for _, m := range []string{"this", "last"} {
for _, u := range []string{"year", "month", "week"} {
term := m + u
if strings.Contains(s, term) {
if m == "last" {
return s0 + "..this" + u
} else {
return s0 + ".."
}
}
}
}
return s0
}