aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKoni Marti <koni.marti@gmail.com>2022-11-15 21:24:52 +0100
committerRobin Jarry <robin@jarry.cc>2022-12-02 22:59:44 +0100
commitd547dca676553a83c077b34f735c9d2ff5d41dcd (patch)
tree0d65703bc29711eda99a9ed8119304bbcd7958e7
parentce7fbd27ee8f41adfa8e33002ccf965c6a917e5a (diff)
downloadaerc-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.md1
-rw-r--r--doc/aerc-search.1.scd19
-rw-r--r--worker/lib/daterange.go418
-rw-r--r--worker/lib/daterange_test.go42
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)
+ }
+ }
+}