From ff101bda430dda18e6f150ce6915891139ecccd9 Mon Sep 17 00:00:00 2001 From: Koni Marti Date: Tue, 15 Nov 2022 21:24:49 +0100 Subject: search: handle date ranges in search/filter query Handle date ranges in the filter and search commands for searching and filtering based on the Date: header. Implement a flag (-d) that accepts a date range where the start date is included in the range but the end date is not, i.e. [start,end). The start or end date can be omitted: "start", "start..", "..end", or "start..end" are all valid inputs. An example filter query would look like this: :filter -d 2022-11-09.. The dates should be in the YYYY-MM-DD format. Signed-off-by: Koni Marti Acked-by: Robin Jarry --- worker/lib/daterange.go | 63 ++++++++++++++++++++++++++++++++++++++++++++ worker/lib/daterange_test.go | 55 ++++++++++++++++++++++++++++++++++++++ worker/lib/search.go | 42 ++++++++++++++++++++++++++--- 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 worker/lib/daterange.go create mode 100644 worker/lib/daterange_test.go (limited to 'worker') diff --git a/worker/lib/daterange.go b/worker/lib/daterange.go new file mode 100644 index 00000000..c996392c --- /dev/null +++ b/worker/lib/daterange.go @@ -0,0 +1,63 @@ +package lib + +import ( + "fmt" + "strings" + "time" +) + +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. +func ParseDateRange(s string) (start, end time.Time, err error) { + s = strings.ReplaceAll(s, " ", "") + i := strings.Index(s, "..") + switch { + case i < 0: + // single date + start, err = time.Parse(dateFmt, 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 = time.Parse(dateFmt, s[2:]) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + + case i > 0: + // start date first + start, err = time.Parse(dateFmt, 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 = time.Parse(dateFmt, s[(i+2):]) + if err != nil { + err = fmt.Errorf("failed to parse date: %w", err) + return + } + } + + return +} diff --git a/worker/lib/daterange_test.go b/worker/lib/daterange_test.go new file mode 100644 index 00000000..983f99a0 --- /dev/null +++ b/worker/lib/daterange_test.go @@ -0,0 +1,55 @@ +package lib_test + +import ( + "testing" + "time" + + "git.sr.ht/~rjarry/aerc/worker/lib" +) + +func TestParseDateRange(t *testing.T) { + dateFmt := "2006-01-02" + parse := func(s string) time.Time { d, _ := time.Parse(dateFmt, s); return d } + tests := []struct { + s string + start time.Time + end time.Time + }{ + { + s: "2022-11-01", + start: parse("2022-11-01"), + end: parse("2022-11-02"), + }, + { + s: "2022-11-01..", + start: parse("2022-11-01"), + }, + { + s: "..2022-11-05", + end: parse("2022-11-05"), + }, + { + s: "2022-11-01..2022-11-05", + start: parse("2022-11-01"), + end: parse("2022-11-05"), + }, + } + + for _, test := range tests { + start, end, err := lib.ParseDateRange(test.s) + if err != nil { + t.Errorf("ParseDateRange return error for %s: %v", + test.s, err) + } + + if !start.Equal(test.start) { + t.Errorf("wrong start date; expected %v, got %v", + test.start, start) + } + + if !end.Equal(test.end) { + t.Errorf("wrong end date; expected %v, got %v", + test.end, end) + } + } +} diff --git a/worker/lib/search.go b/worker/lib/search.go index fe1ec114..c09feff6 100644 --- a/worker/lib/search.go +++ b/worker/lib/search.go @@ -4,10 +4,12 @@ import ( "io" "net/textproto" "strings" + "time" "unicode" "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" ) @@ -18,12 +20,14 @@ type searchCriteria struct { WithFlags []models.Flag WithoutFlags []models.Flag + + startDate, endDate time.Time } func GetSearchCriteria(args []string) (*searchCriteria, error) { criteria := &searchCriteria{Header: make(textproto.MIMEHeader)} - opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:") + opts, optind, err := getopt.Getopts(args, "rux:X:bat:H:f:c:d:") if err != nil { return nil, err } @@ -49,8 +53,18 @@ func GetSearchCriteria(args []string) (*searchCriteria, error) { criteria.Header.Add("Cc", opt.Value) case 'b': body = true - case 'a': - text = true + case 'd': + start, end, err := ParseDateRange(opt.Value) + if err != nil { + log.Errorf("failed to parse start date: %v", err) + continue + } + if !start.IsZero() { + criteria.startDate = start + } + if !end.IsZero() { + criteria.endDate = end + } } } switch { @@ -116,7 +130,7 @@ func searchMessage(message RawMessage, criteria *searchCriteria, return false, err } } - if parts&HEADER > 0 { + if parts&HEADER > 0 || parts&DATE > 0 { header, err = MessageInfo(message) if err != nil { return false, err @@ -188,6 +202,22 @@ func searchMessage(message RawMessage, criteria *searchCriteria, } } } + if parts&DATE > 0 { + if date, err := header.RFC822Headers.Date(); err != nil { + log.Errorf("Failed to get date from header: %v", err) + } else { + if !criteria.startDate.IsZero() { + if date.Before(criteria.startDate) { + return false, nil + } + } + if !criteria.endDate.IsZero() { + if date.After(criteria.endDate) { + return false, nil + } + } + } + } return true, nil } @@ -227,6 +257,7 @@ const NONE MsgParts = 0 const ( FLAGS MsgParts = 1 << iota HEADER + DATE BODY ALL ) @@ -238,6 +269,9 @@ func getRequiredParts(criteria *searchCriteria) MsgParts { if len(criteria.Header) > 0 { required |= HEADER } + if !criteria.startDate.IsZero() || !criteria.endDate.IsZero() { + required |= DATE + } if criteria.Body != nil && len(criteria.Body) > 0 { required |= BODY } -- cgit