aboutsummaryrefslogtreecommitdiffstats
path: root/lib/parse
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-10-17 14:40:08 +0200
committerRobin Jarry <robin@jarry.cc>2023-10-28 19:24:55 +0200
commit57088312fdd8e602a084bd5736a0e22a34be9ec0 (patch)
tree8c5544262cf8c1772ec661748cfa4d5491ff4c77 /lib/parse
parent591659b52867cb118d1f82d41693a02123935e0c (diff)
downloadaerc-57088312fdd8e602a084bd5736a0e22a34be9ec0.tar.gz
worker: move shared code to lib
Avoid importing code from worker/lib into lib. It should only be the other way around. Move the message parsing code used by maildir, notmuch, mbox and the eml viewer into a lib/rfc822 package. Adapt imports accordingly. Signed-off-by: Robin Jarry <robin@jarry.cc> Reviewed-by: Koni Marti <koni.marti@gmail.com> Tested-by: Moritz Poldrack <moritz@poldrack.dev> Tested-by: Inwit <inwit@sindominio.net>
Diffstat (limited to 'lib/parse')
-rw-r--r--lib/parse/daterange.go471
-rw-r--r--lib/parse/daterange_test.go97
2 files changed, 568 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
+}
diff --git a/lib/parse/daterange_test.go b/lib/parse/daterange_test.go
new file mode 100644
index 00000000..ff2ae078
--- /dev/null
+++ b/lib/parse/daterange_test.go
@@ -0,0 +1,97 @@
+package parse_test
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "git.sr.ht/~rjarry/aerc/lib/parse"
+)
+
+func TestParseDateRange(t *testing.T) {
+ dateFmt := "2006-01-02"
+ date := 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: date("2022-11-01"),
+ end: date("2022-11-02"),
+ },
+ {
+ s: "2022-11-01..",
+ start: date("2022-11-01"),
+ },
+ {
+ s: "..2022-11-05",
+ end: date("2022-11-05"),
+ },
+ {
+ s: "2022-11-01..2022-11-05",
+ start: date("2022-11-01"),
+ end: date("2022-11-05"),
+ },
+ }
+
+ for _, test := range tests {
+ start, end, err := parse.DateRange(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)
+ }
+ }
+}
+
+func TestParseRelativeDate(t *testing.T) {
+ tests := []struct {
+ s string
+ want parse.RelDate
+ }{
+ {
+ s: "5 weeks 1 day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5_weeks 1_day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5weeks1day",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5w1d",
+ want: parse.RelDate{Year: 0, Month: 0, Day: 5*7 + 1},
+ },
+ {
+ s: "5y4m3w1d",
+ want: parse.RelDate{Year: 5, Month: 4, Day: 3*7 + 1},
+ },
+ }
+
+ for _, test := range tests {
+ da, err := parse.RelativeDate(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)
+ }
+ }
+}