diff options
author | Koni Marti <koni.marti@gmail.com> | 2022-11-15 21:24:52 +0100 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2022-12-02 22:59:44 +0100 |
commit | d547dca676553a83c077b34f735c9d2ff5d41dcd (patch) | |
tree | 0d65703bc29711eda99a9ed8119304bbcd7958e7 | |
parent | ce7fbd27ee8f41adfa8e33002ccf965c6a917e5a (diff) | |
download | aerc-d547dca676553a83c077b34f735c9d2ff5d41dcd.tar.gz |
daterange: support relative terms
Support relative terms when writing date ranges in the search and filter
commands with the -d flag. Syntax is inspired by the notmuch search
terms.
Terms can be written with spaces or underscores for a better
readability, so both "this_week" and "this week" are allowed. Terms are
not case-sensitive.
Some terms can be prefixed with either "this" or "last" where applicable
("this" is assumed by default if omitted):
- "today", "yesterday"
- ("this"|"last") "year", "month", "week"
- all weekdays (e.g. "Tuesday", "last_wed")
- all months (e.g. "January", "last_feb")
Note that "month" should always be spelled out to prevent a possible
ambiguity with "Monday".
Weekdays and months do not need to be written out completely, i.e.
"February..March" and "Feb..Mar" are both understood.
Relative date terms can be used with the <N (year|month|week|day)>
syntax where N is a positive integer indicating the number of time units
in the past from today. The units can be abbreviated with a single
letter, e.g. "1w 1d.." is the same as "1 week 1 day..".
More examples:
:filter -d yesterday
:filter -d last_monday..
:filter -d mon..sat
:filter -d 1y1m1w1d..
:search -d this_week "PATCH aerc"
Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
-rw-r--r-- | CHANGELOG.md | 1 | ||||
-rw-r--r-- | doc/aerc-search.1.scd | 19 | ||||
-rw-r--r-- | worker/lib/daterange.go | 418 | ||||
-rw-r--r-- | worker/lib/daterange_test.go | 42 |
4 files changed, 475 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6a1d92..f356122c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). defined in their environment.` - Override the subject prefix prefix for replies pattern with `subject-re-pattern`. +- Search/filter by absolute and relative date ranges with the `-d` flag. ### Fixed diff --git a/doc/aerc-search.1.scd b/doc/aerc-search.1.scd index 51e93ade..8349874b 100644 --- a/doc/aerc-search.1.scd +++ b/doc/aerc-search.1.scd @@ -44,6 +44,25 @@ aerc-search - search and filter patterns and options for *aerc*(1) Search for messages within a particular date range defined as \[start, end) where the dates are in the YYYY-MM-DD format. + Relative dates can be used to specify a date range. Spaces and + underscores are allowed to improve readability: + + *today*, *yesterday* + + *(this|last) (year|month|week)* + + *Weekdays*, *Monthnames* + Can also be abbreviate, so Monday..Tuesday can written + as Mon..Tue and February..March as Feb..Mar. + + _<N>_ *(y[ear]|m[onth]|w[eek]|d[ay])* + _<N>_ is a positive integer that represents the number + of the time units in the past. Mutiple relative terms + can will be accumulated. The units can also be + abbreviated by a single letter such that yesterday would + correspond to _1d_ (equivalent to _1 day_ or _1_day_) + and _8 days ago_ would be either _1w1d_ or _8d_. + # NOTMUCH *search* _query_... diff --git a/worker/lib/daterange.go b/worker/lib/daterange.go index c996392c..b08bf177 100644 --- a/worker/lib/daterange.go +++ b/worker/lib/daterange.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "time" + + "git.sr.ht/~rjarry/aerc/log" ) const dateFmt = "2006-01-02" @@ -16,13 +18,16 @@ const dateFmt = "2006-01-02" // // 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 ParseDateRange(s string) (start, end time.Time, err error) { - s = strings.ReplaceAll(s, " ", "") + s = cleanInput(s) + s = ensureRangeOp(s) i := strings.Index(s, "..") switch { case i < 0: // single date - start, err = time.Parse(dateFmt, s) + start, err = translate(s) if err != nil { err = fmt.Errorf("failed to parse date: %w", err) return @@ -35,7 +40,7 @@ func ParseDateRange(s string) (start, end time.Time, err error) { err = fmt.Errorf("no date found") return } - end, err = time.Parse(dateFmt, s[2:]) + end, err = translate(s[2:]) if err != nil { err = fmt.Errorf("failed to parse date: %w", err) return @@ -43,7 +48,7 @@ func ParseDateRange(s string) (start, end time.Time, err error) { case i > 0: // start date first - start, err = time.Parse(dateFmt, s[:i]) + start, err = translate(s[:i]) if err != nil { err = fmt.Errorf("failed to parse date: %w", err) return @@ -52,7 +57,7 @@ func ParseDateRange(s string) (start, end time.Time, err error) { return } // and end dates if available - end, err = time.Parse(dateFmt, s[(i+2):]) + end, err = translate(s[(i + 2):]) if err != nil { err = fmt.Errorf("failed to parse date: %w", err) return @@ -61,3 +66,406 @@ func ParseDateRange(s string) (start, end time.Time, err error) { 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 := ParseRelativeDate(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 ParseRelativeDate(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 +} diff --git a/worker/lib/daterange_test.go b/worker/lib/daterange_test.go index 983f99a0..807e7ac7 100644 --- a/worker/lib/daterange_test.go +++ b/worker/lib/daterange_test.go @@ -1,6 +1,7 @@ package lib_test import ( + "reflect" "testing" "time" @@ -53,3 +54,44 @@ func TestParseDateRange(t *testing.T) { } } } + +func TestParseRelativeDate(t *testing.T) { + tests := []struct { + s string + want lib.RelDate + }{ + { + s: "5 weeks 1 day", + want: lib.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5_weeks 1_day", + want: lib.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5weeks1day", + want: lib.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5w1d", + want: lib.RelDate{Year: 0, Month: 0, Day: 5*7 + 1}, + }, + { + s: "5y4m3w1d", + want: lib.RelDate{Year: 5, Month: 4, Day: 3*7 + 1}, + }, + } + + for _, test := range tests { + da, err := lib.ParseRelativeDate(test.s) + if err != nil { + t.Errorf("ParseRelativeDate return error for %s: %v", + test.s, err) + } + + if !reflect.DeepEqual(da, test.want) { + t.Errorf("results don't match. expected %v, got %v", + test.want, da) + } + } +} |