diff options
Diffstat (limited to 'lib/parse/daterange.go')
-rw-r--r-- | lib/parse/daterange.go | 471 |
1 files changed, 471 insertions, 0 deletions
diff --git a/lib/parse/daterange.go b/lib/parse/daterange.go new file mode 100644 index 00000000..bd984ed5 --- /dev/null +++ b/lib/parse/daterange.go @@ -0,0 +1,471 @@ +package parse + +import ( + "fmt" + "strings" + "time" + + "git.sr.ht/~rjarry/aerc/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 +} |