diff options
author | Robin Jarry <robin@jarry.cc> | 2023-10-17 14:40:08 +0200 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-10-28 19:24:55 +0200 |
commit | 57088312fdd8e602a084bd5736a0e22a34be9ec0 (patch) | |
tree | 8c5544262cf8c1772ec661748cfa4d5491ff4c77 /lib/parse | |
parent | 591659b52867cb118d1f82d41693a02123935e0c (diff) | |
download | aerc-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.go | 471 | ||||
-rw-r--r-- | lib/parse/daterange_test.go | 97 |
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) + } + } +} |