From 455010dc6412592bebb5f894799414c2604efab8 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Thu, 19 Oct 2023 13:13:11 +0200 Subject: [PATCH] Accept days, months and years in time ranges. (#483) --- docs/csaf_downloader.md | 13 ++++--- internal/models/models.go | 69 ++++++++++++++++++++++++++++++++-- internal/models/models_test.go | 31 +++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 74060712..0fe4e859 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -102,11 +102,14 @@ into a given intervall. There are three possible notations: 1. Relative. If the given string follows the rules of a [Go duration](https://pkg.go.dev/time@go1.20.6#ParseDuration), the time interval from now going back that duration is used. - Some examples: - - `"3h"` means downloading the advisories that have changed in the last three hours. - - `"30m"` .. changed within the last thirty minutes. - - `"72h"` .. changed within the last three days. - - `"8760h"` .. changed within the last 365 days. + In extension to this the suffixes 'd' for days, 'M' for month + and 'y' for years are recognized. In these cases only integer + values are accepted without any fractions. + Some examples: + - `"3h"` means downloading the advisories that have changed in the last three hours. + - `"30m"` .. changed within the last thirty minutes. + - `"3M2m"` .. changed within the last three months and two minutes. + - `"2y"` .. changed within the last two years. 2. Absolute. If the given string is an RFC 3339 date timestamp the time interval between this date and now is used. diff --git a/internal/models/models.go b/internal/models/models.go index fad85c34..00fead38 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -12,7 +12,10 @@ package models import ( "encoding/json" "fmt" + "regexp" + "strconv" "strings" + "sync" "time" ) @@ -59,6 +62,65 @@ func (tr *TimeRange) UnmarshalText(text []byte) error { return tr.UnmarshalFlag(string(text)) } +var ( + yearsMonthsDays *regexp.Regexp + yearsMonthsDaysOnce sync.Once +) + +// parseDuration extends time.ParseDuration with recognition of +// years, month and days with the suffixes "y", "M" and "d". +// Onlys integer values are detected. The handling of fractional +// values would increase the complexity and may be done in the future. +// The calculate dates are assumed to be before the reference time. +func parseDuration(s string, reference time.Time) (time.Duration, error) { + var ( + extra time.Duration + err error + used bool + ) + parse := func(s string) int { + if err == nil { + var v int + v, err = strconv.Atoi(s) + return v + } + return 0 + } + // Only compile expression if really needed. + yearsMonthsDaysOnce.Do(func() { + yearsMonthsDays = regexp.MustCompile(`[-+]?[0-9]+[yMd]`) + }) + s = yearsMonthsDays.ReplaceAllStringFunc(s, func(part string) string { + used = true + var years, months, days int + switch suf, num := part[len(part)-1], part[:len(part)-1]; suf { + case 'y': + years = -parse(num) + case 'M': + months = -parse(num) + case 'd': + days = -parse(num) + } + date := reference.AddDate(years, months, days) + extra += reference.Sub(date) + // Remove from string + return "" + }) + if err != nil { + return 0, err + } + // If there is no rest we don't need the stdlib parser. + if used && s == "" { + return extra, nil + } + // Parse the rest with the stdlib. + d, err := time.ParseDuration(s) + if err != nil { + return d, err + } + return d + extra, nil +} + // MarshalJSON implements [encoding/json.Marshaler]. func (tr TimeRange) MarshalJSON() ([]byte, error) { s := []string{ @@ -72,9 +134,10 @@ func (tr TimeRange) MarshalJSON() ([]byte, error) { func (tr *TimeRange) UnmarshalFlag(s string) error { s = strings.TrimSpace(s) + now := time.Now() + // Handle relative case first. - if duration, err := time.ParseDuration(s); err == nil { - now := time.Now() + if duration, err := parseDuration(s, now); err == nil { *tr = NewTimeInterval(now.Add(-duration), now) return nil } @@ -88,7 +151,7 @@ func (tr *TimeRange) UnmarshalFlag(s string) error { if !ok { return fmt.Errorf("%q is not a valid RFC date time", a) } - *tr = NewTimeInterval(start, time.Now()) + *tr = NewTimeInterval(start, now) return nil } // Real interval diff --git a/internal/models/models_test.go b/internal/models/models_test.go index ffbcb9c0..0217bf77 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -9,6 +9,7 @@ package models import ( + "strings" "testing" "time" ) @@ -25,6 +26,36 @@ func TestNewTimeInterval(t *testing.T) { } } +func TestParseDuration(t *testing.T) { + + now := time.Now() + + for _, x := range []struct { + in string + expected time.Duration + reference time.Time + fail bool + }{ + {"1h", time.Hour, now, false}, + {"2y", now.Sub(now.AddDate(-2, 0, 0)), now, false}, + {"13M", now.Sub(now.AddDate(0, -13, 0)), now, false}, + {"31d", now.Sub(now.AddDate(0, 0, -31)), now, false}, + {"1h2d3m", now.Sub(now.AddDate(0, 0, -2)) + time.Hour + 3*time.Minute, now, false}, + {strings.Repeat("1", 70) + "y1d", 0, now, true}, + } { + got, err := parseDuration(x.in, x.reference) + if err != nil { + if !x.fail { + t.Errorf("%q should not fail: %v", x.in, err) + } + continue + } + if got != x.expected { + t.Errorf("%q got %v expected %v", x.in, got, x.expected) + } + } +} + // TestGuessDate tests whether a sample of strings are correctly parsed into Dates by guessDate() func TestGuessDate(t *testing.T) { if _, guess := guessDate("2006-01-02T15:04:05"); !guess {