diff --git a/docs/content/configuration/templating.md b/docs/content/configuration/templating.md index 21a9804..720828b 100644 --- a/docs/content/configuration/templating.md +++ b/docs/content/configuration/templating.md @@ -256,6 +256,19 @@ Example: < 5 hours, 33 minutes, 12 seconds - 5 hours, 33 minutes ``` +### `formatHumanDateDiff` + +Formats a DateInterval object according to the format (%Y, %M, %D, %H, %I, %S for years, months, days, hours, minutes, seconds - Lowercase letters without leading zeros) + +Syntax: `formatHumanDateDiff ` + +Example: + +``` +# {{ humanDateDiff (mustToDate "2006-01-02 -0700" "2024-05-05 +0200") (mustToDate "2006-01-02 -0700" "2023-01-09 +0100") | formatHumanDateDiff "%Y years, %M months, %D days" }} +< 01 years, 03 months, 25 days +``` + ### `group` Gets matching group specified by index from `match_message` regular expression, when `fallback` is defined, it is used when group has an empty match @@ -271,6 +284,19 @@ Example: < test - oops ``` +### `humanDateDiff` + +Returns a DateInterval object describing the time difference between a and b in a "human" way of counting the time (2023-02-05 -> 2024-03-05 = 1 Year, 1 Month) + +Syntax: `humanDateDiff ` + +Example: + +``` +# {{ humanDateDiff (mustToDate "2006-01-02 -0700" "2024-05-05 +0200") (mustToDate "2006-01-02 -0700" "2023-01-09 +0100") }} +< {1 3 25 23 0 0} +``` + ### `idForUsername` Returns the user-id for the given username @@ -441,7 +467,7 @@ Example: ``` # Your int this hour: {{ printf "%.0f" (mulf (seededRandom (list "int" .username (now | date "2006-01-02 15") | join ":")) 100) }}% -< Your int this hour: 27% +< Your int this hour: 70% ``` ### `spotifyCurrentPlaying` diff --git a/internal/template/date/date.go b/internal/template/date/date.go new file mode 100644 index 0000000..c916ead --- /dev/null +++ b/internal/template/date/date.go @@ -0,0 +1,31 @@ +// Package date adds date-based helper functions for templating +package date + +import ( + "github.com/Luzifer/twitch-bot/v3/plugins" +) + +// Register provides the plugins.RegisterFunc +func Register(args plugins.RegistrationArguments) error { + args.RegisterTemplateFunction("humanDateDiff", plugins.GenericTemplateFunctionGetter(NewInterval), plugins.TemplateFuncDocumentation{ + Description: `Returns a DateInterval object describing the time difference between a and b in a "human" way of counting the time (2023-02-05 -> 2024-03-05 = 1 Year, 1 Month)`, + Syntax: "humanDateDiff ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ humanDateDiff (mustToDate "2006-01-02 -0700" "2024-05-05 +0200") (mustToDate "2006-01-02 -0700" "2023-01-09 +0100") }}`, + ExpectedOutput: "{1 3 25 23 0 0}", + }, + }) + + args.RegisterTemplateFunction("formatHumanDateDiff", plugins.GenericTemplateFunctionGetter(func(format string, d Interval) string { + return d.Format(format) + }), plugins.TemplateFuncDocumentation{ + Description: "Formats a DateInterval object according to the format (%Y, %M, %D, %H, %I, %S for years, months, days, hours, minutes, seconds - Lowercase letters without leading zeros)", + Syntax: "formatHumanDateDiff ", + Example: &plugins.TemplateFuncDocumentationExample{ + Template: `{{ humanDateDiff (mustToDate "2006-01-02 -0700" "2024-05-05 +0200") (mustToDate "2006-01-02 -0700" "2023-01-09 +0100") | formatHumanDateDiff "%Y years, %M months, %D days" }}`, + ExpectedOutput: "01 years, 03 months, 25 days", + }, + }) + + return nil +} diff --git a/internal/template/date/interval.go b/internal/template/date/interval.go new file mode 100644 index 0000000..8ce65b6 --- /dev/null +++ b/internal/template/date/interval.go @@ -0,0 +1,94 @@ +package date + +import ( + "fmt" + "strings" + "time" +) + +type ( + // Interval represents a human-minded diff of two dates which is in + // no way interchangeable with a time.Duration: The DateInterval + // takes each date component and subtracts them. This causes the + // 03/25 to be exactly one month distant from 02/25 even though the + // distance would be different than with 03/25 and 04/25 which is + // also exactly one month. + Interval struct { + Years int + Months int + Days int + Hours int + Minutes int + Seconds int + } +) + +// NewInterval creates an Interval from two given dates +func NewInterval(a, b time.Time) (i Interval) { + var l, u time.Time + if a.Before(b) { + l, u = a.UTC(), b.UTC() + } else { + l, u = b.UTC(), a.UTC() + } + + i.Years = u.Year() - l.Year() + i.Months = int(u.Month() - l.Month()) + i.Days = u.Day() - l.Day() + i.Hours = u.Hour() - l.Hour() + i.Minutes = u.Minute() - l.Minute() + i.Seconds = u.Second() - l.Second() + + if i.Seconds < 0 { + i.Minutes, i.Seconds = i.Minutes-1, i.Seconds+60 //nolint:gomnd + } + + if i.Minutes < 0 { + i.Hours, i.Minutes = i.Hours-1, i.Minutes+60 //nolint:gomnd + } + + if i.Hours < 0 { + i.Days, i.Hours = i.Days-1, i.Hours+24 //nolint:gomnd + } + + if i.Days < 0 { + // oh boi. + i.Months, i.Days = i.Months-1, daysInMonth(u.Year(), int(u.Month())-1)+i.Days + } + + if i.Months < 0 { + i.Years, i.Months = i.Years-1, i.Months+12 //nolint:gomnd + } + + return i +} + +func daysInMonth(year, month int) int { + return time.Date(year, time.Month(month+1), 1, 0, 0, 0, 0, time.Local).Add(-time.Second).Day() +} + +// Format takes a template string analog to a strftime string and formats +// the Interval accordingly: +// +// %Y / %y = Years with / without leading digit to 2 places +// %M / %m = Months with / without leading digit to 2 places +// %D / %d = Days with / without leading digit to 2 places +// %H / %h = Hours with / without leading digit to 2 places +// %I / %i = Minutes with / without leading digit to 2 places +// %S / %s = Seconds with / without leading digit to 2 places +func (i Interval) Format(tplString string) string { + return strings.NewReplacer( + "%Y", fmt.Sprintf("%02d", i.Years), + "%y", fmt.Sprintf("%d", i.Years), + "%M", fmt.Sprintf("%02d", i.Months), + "%m", fmt.Sprintf("%d", i.Months), + "%D", fmt.Sprintf("%02d", i.Days), + "%d", fmt.Sprintf("%d", i.Days), + "%H", fmt.Sprintf("%02d", i.Hours), + "%h", fmt.Sprintf("%d", i.Hours), + "%I", fmt.Sprintf("%02d", i.Minutes), + "%i", fmt.Sprintf("%d", i.Minutes), + "%S", fmt.Sprintf("%02d", i.Seconds), + "%s", fmt.Sprintf("%d", i.Seconds), + ).Replace(tplString) +} diff --git a/internal/template/date/interval_test.go b/internal/template/date/interval_test.go new file mode 100644 index 0000000..ac8e792 --- /dev/null +++ b/internal/template/date/interval_test.go @@ -0,0 +1,134 @@ +package date + +import ( + _ "embed" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +//go:embed tzdata +var tzDataEuropeBerlin []byte + +//nolint:funlen // This is just a collection of test cases +func TestNewInterval(t *testing.T) { + tz, err := time.LoadLocationFromTZData("Europe/Berlin", tzDataEuropeBerlin) + require.NoError(t, err) + + for i, tc := range []struct { + A, B time.Time + Exp Interval + }{ + { + // Plain and simple: 1 Month + A: time.Date(2024, 3, 3, 0, 0, 0, 0, tz), + B: time.Date(2024, 2, 3, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 0, 0, 0, 0}, + }, + { + // Plain and simple: 1 Month, reversed + A: time.Date(2024, 2, 3, 0, 0, 0, 0, tz), + B: time.Date(2024, 3, 3, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 0, 0, 0, 0}, + }, + { + // Plain and simple: 1 Year, 1 Month + A: time.Date(2023, 2, 3, 0, 0, 0, 0, tz), + B: time.Date(2024, 3, 3, 0, 0, 0, 0, tz), + Exp: Interval{1, 1, 0, 0, 0, 0}, + }, + { + // 11 Months, so Year and Month needs to be adjusted + A: time.Date(2023, 3, 3, 0, 0, 0, 0, tz), + B: time.Date(2024, 2, 3, 0, 0, 0, 0, tz), + Exp: Interval{0, 11, 0, 0, 0, 0}, + }, + { + // Plain and simple: 2 Days + A: time.Date(2024, 3, 3, 0, 0, 0, 0, tz), + B: time.Date(2024, 3, 5, 0, 0, 0, 0, tz), + Exp: Interval{0, 0, 2, 0, 0, 0}, + }, + { + // 1 Month and a few days, so Month and Day needs to be adjusted + A: time.Date(2024, 3, 25, 0, 0, 0, 0, tz), + B: time.Date(2024, 5, 5, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 9, 23, 0, 0}, + }, + { + // 1 Month and a few days, so Month and Day needs to be adjusted + A: time.Date(2024, 2, 25, 0, 0, 0, 0, tz), + B: time.Date(2024, 4, 5, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 10, 23, 0, 0}, + }, + { + // 1 Month and a few days, so Month and Day needs to be adjusted + A: time.Date(2024, 1, 25, 0, 0, 0, 0, tz), + B: time.Date(2024, 3, 5, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 9, 0, 0, 0}, + }, + { + // 1 Month and a few days, so Month and Day needs to be adjusted + A: time.Date(2023, 1, 25, 0, 0, 0, 0, tz), + B: time.Date(2023, 3, 5, 0, 0, 0, 0, tz), + Exp: Interval{0, 1, 8, 0, 0, 0}, + }, + { + // 1 Day and a few hours, so Day and Hours needs to be adjusted + A: time.Date(2024, 3, 5, 14, 0, 0, 0, tz), + B: time.Date(2024, 3, 7, 0, 0, 0, 0, tz), + Exp: Interval{0, 0, 1, 10, 0, 0}, + }, + { + // 1 Hour and a few minutes, so Hours and Minutes needs to be adjusted + A: time.Date(2024, 3, 5, 14, 25, 0, 0, tz), + B: time.Date(2024, 3, 5, 16, 12, 0, 0, tz), + Exp: Interval{0, 0, 0, 1, 47, 0}, + }, + { + // 1 Minute and a few seconds, so Minutes and Seconds needs to be adjusted + A: time.Date(2024, 3, 5, 14, 25, 13, 0, tz), + B: time.Date(2024, 3, 5, 14, 27, 0, 0, tz), + Exp: Interval{0, 0, 0, 0, 1, 47}, + }, + { + // Nearly one year but a few seconds, everything needs to be adjusted + A: time.Date(2024, 3, 5, 14, 25, 0, 0, tz), + B: time.Date(2023, 3, 5, 14, 25, 13, 0, tz), + Exp: Interval{0, 11, 28, 23, 59, 47}, + }, + { + // Nearly one year but a few seconds, everything needs to be adjusted + A: time.Date(2024, 8, 5, 14, 25, 0, 0, tz), + B: time.Date(2023, 8, 5, 14, 25, 13, 0, tz), + Exp: Interval{0, 11, 30, 23, 59, 47}, + }, + { + // Nearly one year but a few seconds, everything needs to be adjusted + A: time.Date(2024, 7, 5, 14, 25, 0, 0, tz), + B: time.Date(2023, 7, 5, 14, 25, 13, 0, tz), + Exp: Interval{0, 11, 29, 23, 59, 47}, + }, + { + // Nearly one year but a few seconds, everything needs to be adjusted + A: time.Date(2024, 2, 5, 14, 25, 0, 0, tz), + B: time.Date(2023, 2, 5, 14, 25, 13, 0, tz), + Exp: Interval{0, 11, 30, 23, 59, 47}, + }, + } { + assert.Equal(t, + tc.Exp, + NewInterval(tc.A, tc.B), + fmt.Sprintf("%d: %s -> %s", i, tc.A, tc.B)) + } +} + +func TestFormatInterval(t *testing.T) { + ti := Interval{1, 2, 3, 4, 5, 6} + assert.Equal(t, + "01 1 years 02 2 months 03 3 days 04 4 hours 05 5 minutes 06 6 seconds", + ti.Format("%Y %y years %M %m months %D %d days %H %h hours %I %i minutes %S %s seconds")) +} diff --git a/internal/template/date/tzdata b/internal/template/date/tzdata new file mode 100644 index 0000000..7f6d958 Binary files /dev/null and b/internal/template/date/tzdata differ diff --git a/plugins_core.go b/plugins_core.go index 8cd61c8..7c66470 100644 --- a/plugins_core.go +++ b/plugins_core.go @@ -46,6 +46,7 @@ import ( "github.com/Luzifer/twitch-bot/v3/internal/apimodules/raffle" "github.com/Luzifer/twitch-bot/v3/internal/service/access" "github.com/Luzifer/twitch-bot/v3/internal/template/api" + "github.com/Luzifer/twitch-bot/v3/internal/template/date" "github.com/Luzifer/twitch-bot/v3/internal/template/numeric" "github.com/Luzifer/twitch-bot/v3/internal/template/random" "github.com/Luzifer/twitch-bot/v3/internal/template/slice" @@ -93,6 +94,7 @@ var ( // Template functions api.Register, + date.Register, numeric.Register, random.Register, slice.Register,