diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ceb54b..2330916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ N/A +## [1.10.0] - 2023-10-01 + +### Added + +* Support "LW" in day-of-month. (e.g "0 0 LW * ? *") + ## [1.9.2] - 2023-09-30 ### Fixed diff --git a/internal/util/date.go b/internal/util/date.go index c740a75..d41c205 100644 --- a/internal/util/date.go +++ b/internal/util/date.go @@ -45,6 +45,16 @@ func LastWdayOfMonth(t time.Time, w time.Weekday) int { } } +func LastWeekdayOfMonth(t time.Time) int { + lom := t.AddDate(0, 1, -t.Day()) + + for i := lom; ; i = i.AddDate(0, 0, -1) { + if i.Weekday() != time.Saturday && i.Weekday() != time.Sunday { + return i.Day() + } + } +} + func NearestWeekday(t2 time.Time, day int) int { base := time.Date(t2.Year(), t2.Month(), day, 0, 0, 0, 0, t2.Location()) lom := LastOfMonth(time.Date(t2.Year(), t2.Month(), 1, 0, 0, 0, 0, t2.Location())) diff --git a/internal/util/date_test.go b/internal/util/date_test.go index 3e05bd5..e15e2f1 100644 --- a/internal/util/date_test.go +++ b/internal/util/date_test.go @@ -284,3 +284,41 @@ func TestNthDayOfWeek(t *testing.T) { assert.Equal(t.expected, util.NthDayOfWeek(t.tm, t.w, t.nth), fmt.Sprintf("%s %v", t.tm, t)) } } + +func TestLastWeekdayOfMonth(t *testing.T) { + assert := assert.New(t) + + tt := []struct { + tm time.Time + expected int + }{ + {time.Date(2023, 1, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 2, 1, 9, 0, 0, 0, time.UTC), 28}, + {time.Date(2023, 3, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 4, 1, 9, 0, 0, 0, time.UTC), 28}, + {time.Date(2023, 5, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 6, 1, 9, 0, 0, 0, time.UTC), 30}, + {time.Date(2023, 7, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 8, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 9, 1, 9, 0, 0, 0, time.UTC), 29}, + {time.Date(2023, 10, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2023, 11, 1, 9, 0, 0, 0, time.UTC), 30}, + {time.Date(2023, 12, 1, 9, 0, 0, 0, time.UTC), 29}, + {time.Date(2024, 1, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2024, 2, 1, 9, 0, 0, 0, time.UTC), 29}, + {time.Date(2024, 3, 1, 9, 0, 0, 0, time.UTC), 29}, + {time.Date(2024, 4, 1, 9, 0, 0, 0, time.UTC), 30}, + {time.Date(2024, 5, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC), 28}, + {time.Date(2024, 7, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2024, 8, 1, 9, 0, 0, 0, time.UTC), 30}, + {time.Date(2024, 9, 1, 9, 0, 0, 0, time.UTC), 30}, + {time.Date(2024, 10, 1, 9, 0, 0, 0, time.UTC), 31}, + {time.Date(2024, 11, 1, 9, 0, 0, 0, time.UTC), 29}, + {time.Date(2024, 12, 1, 9, 0, 0, 0, time.UTC), 31}, + } + + for _, t := range tt { + assert.Equal(t.expected, util.LastWeekdayOfMonth(t.tm), t.tm) + } +} diff --git a/match.go b/match.go index 6436ba5..3dc0304 100644 --- a/match.go +++ b/match.go @@ -227,9 +227,15 @@ func (v *LastDayOfMonth) Match(t time.Time) bool { return util.LastOfMonth(t)-v.Int() == t.Day() } +func (v *LastWeekdayOfMonth) Match(t time.Time) bool { + return util.LastWeekdayOfMonth(t) == t.Day() +} + func (e *DayOfMonthExp) Match(t time.Time) bool { if e.NearestWeekday != nil { return e.NearestWeekday.Match(t) + } else if e.LastWeekday != nil { + return e.LastWeekday.Match(t) } else if e.Last != nil { return e.Last.Match(t) } else if e.Bottom != nil { diff --git a/match_day_of_month_test.go b/match_day_of_month_test.go index 46926f3..0c68f56 100644 --- a/match_day_of_month_test.go +++ b/match_day_of_month_test.go @@ -175,6 +175,62 @@ func TestMatchDayObMonth(t *testing.T) { {time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC), true}, }, }, + { + exp: "* * LW * ? *", + tests: []struct { + tm time.Time + expected bool + }{ + {time.Date(2023, 1, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 2, 28, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 3, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 4, 28, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 5, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 6, 30, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 7, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 8, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 9, 29, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 10, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 11, 30, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 12, 29, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 1, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 2, 29, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 3, 29, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 4, 30, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 5, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 6, 28, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 7, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 8, 30, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 9, 30, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 10, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 11, 29, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2024, 12, 31, 9, 0, 0, 0, time.UTC), true}, + {time.Date(2023, 1, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 2, 27, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 3, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 4, 27, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 5, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 6, 29, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 7, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 8, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 9, 28, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 10, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 11, 29, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2023, 12, 28, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 1, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 2, 28, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 3, 31, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 4, 29, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 5, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 6, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 7, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 8, 31, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 9, 20, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 10, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 11, 30, 9, 0, 0, 0, time.UTC), false}, + {time.Date(2024, 12, 30, 9, 0, 0, 0, time.UTC), false}, + }, + }, } for _, t := range tt { diff --git a/next_test.go b/next_test.go index e0064cf..7664d6b 100644 --- a/next_test.go +++ b/next_test.go @@ -116,6 +116,11 @@ func TestNext(t *testing.T) { from: time.Date(2022, 10, 10, 9, 0, 0, 0, time.UTC), expected: time.Date(2022, 10, 10, 12, 34, 0, 0, time.UTC), }, + { + exp: "35 13 LW * ? *", + from: time.Date(2022, 10, 10, 9, 0, 0, 0, time.UTC), + expected: time.Date(2022, 10, 31, 13, 35, 0, 0, time.UTC), + }, } for _, t := range tt { @@ -362,6 +367,15 @@ func TestNextN_3(t *testing.T) { from: time.Date(2022, 10, 10, 0, 0, 0, 0, time.UTC), expected: []time.Time{}, }, + { + exp: "34 8 LW * ? *", + from: time.Date(2022, 10, 10, 0, 0, 0, 0, time.UTC), + expected: []time.Time{ + time.Date(2022, 10, 31, 8, 34, 0, 0, time.UTC), + time.Date(2022, 11, 30, 8, 34, 0, 0, time.UTC), + time.Date(2022, 12, 30, 8, 34, 0, 0, time.UTC), + }, + }, } for _, t := range tt { diff --git a/parse.go b/parse.go index 06a2ef7..e15b890 100644 --- a/parse.go +++ b/parse.go @@ -293,13 +293,25 @@ func (v *LastDayOfMonth) String() string { } } +type LastWeekdayOfMonth struct{} + +func (v *LastWeekdayOfMonth) Capture(values []string) error { + *v = LastWeekdayOfMonth{} + return nil +} + +func (v *LastWeekdayOfMonth) String() string { + return "LW" +} + type DayOfMonthExp struct { - NearestWeekday *NearestWeekday `( @Number "W" )` - Wildcard bool `| ( ( @"*"` - Range *DayOfMonthRange ` | @@` - Number *DayOfMonth ` | @Number )` - Bottom *int ` ( "/" @Number )? )` - Last *LastDayOfMonth `| ( @"L" ( "-" @Number )? )` + NearestWeekday *NearestWeekday `( @Number "W" )` + Wildcard bool `| ( ( @"*"` + Range *DayOfMonthRange ` | @@` + Number *DayOfMonth ` | @Number )` + Bottom *int ` ( "/" @Number )? )` + LastWeekday *LastWeekdayOfMonth `| ( @"L" "W" )` + Last *LastDayOfMonth `| ( @"L" ( "-" @Number )? )` } func (e *DayOfMonthExp) String() string { @@ -311,6 +323,8 @@ func (e *DayOfMonthExp) String() string { s = e.Range.String() } else if e.Number != nil { s = e.Number.String() + } else if e.LastWeekday != nil { + s = e.LastWeekday.String() } else if e.Last != nil { s = e.Last.String() } else if e.NearestWeekday != nil { diff --git a/parse_day_of_month_test.go b/parse_day_of_month_test.go index bbd1c76..62e460e 100644 --- a/parse_day_of_month_test.go +++ b/parse_day_of_month_test.go @@ -106,9 +106,16 @@ func TestDayOfMonthWeekday(t *testing.T) { assert.Equal(nwday(3), cron.DayOfMonth.Exps[0].NearestWeekday) } +func TestDayOfMonthLastWeekday(t *testing.T) { + assert := assert.New(t) + cron, err := cronplan.Parse("* * LW * ? *") + assert.NoError(err) + assert.Equal(&cronplan.LastWeekdayOfMonth{}, cron.DayOfMonth.Exps[0].LastWeekday) +} + func TestDayOfMonthComplex(t *testing.T) { assert := assert.New(t) - cron, err := cronplan.Parse("* * *,1,1-30,1/5,*/5,L,3W * ? *") + cron, err := cronplan.Parse("* * *,1,1-30,1/5,*/5,L,3W,LW * ? *") assert.NoError(err) assert.True(cron.DayOfMonth.Exps[0].Wildcard) assert.Equal(day(1), cron.DayOfMonth.Exps[1].Number) @@ -126,4 +133,5 @@ func TestDayOfMonthComplex(t *testing.T) { }, cron.DayOfMonth.Exps[4]) assert.Equal(last(0), cron.DayOfMonth.Exps[5].Last) assert.Equal(nwday(3), cron.DayOfMonth.Exps[6].NearestWeekday) + assert.Equal(&cronplan.LastWeekdayOfMonth{}, cron.DayOfMonth.Exps[7].LastWeekday) }