aboutsummaryrefslogblamecommitdiffstats
path: root/lib/parse/daterange.go
blob: 26368609cb7d84c9fd54680685f1208e1d04f543 (plain) (tree)
1
2
3
4
5
6
7
8
             




                 
 
                                        











                                                                              

                                                                            
                                                            

                            



                                   
                                         











                                                                         
                                           






                                                                         
                                             







                                                                         
                                                 







                                                                         



































































































































































































































                                                                               
                                               












































































                                                                                 
                                              































































































                                                                              
package parse

import (
	"fmt"
	"strings"
	"time"

	"git.sr.ht/~rjarry/aerc/lib/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
}