-
-
Save mmunchandersen/cd5038957a5c29515a3b0b91289fa02b to your computer and use it in GitHub Desktop.
PowerShell cron expression parser, to check if a date/time matches a cron expression
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
.DESCRIPTION | |
PowerShell cron expression parser, to check if a date/time matches a cron expression | |
Format: | |
<min> <hour> <day-of-month> <month> <day-of-week> | |
.PARAMETER Expression | |
A cron expression to validate | |
.PARAMETER DateTime | |
[Optional] A specific date/time to check cron expression against. (Default: DateTime.Now) | |
.EXAMPLE Test expression against the current date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' | |
.EXAMPLE Test expression against a specific date/time | |
Test-CronExpression -Expression '5/7 * 29 FEB,MAR *' -DateTime ([DateTime]::Now) | |
#> | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression, | |
[Parameter()] | |
$DateTime = $null | |
) | |
function Get-CronFields | |
{ | |
return @( | |
'Minute', | |
'Hour', | |
'DayOfMonth', | |
'Month', | |
'DayOfWeek' | |
) | |
} | |
function Get-CronFieldConstraints | |
{ | |
return @{ | |
'MinMax' = @( | |
@(0, 59), | |
@(0, 23), | |
@(1, 31), | |
@(1, 12), | |
@(0, 6) | |
); | |
'DaysInMonths' = @( | |
31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 | |
); | |
'Months' = @( | |
'January', 'February', 'March', 'April', 'May', 'June', 'July', | |
'August', 'September', 'October', 'November', 'December' | |
) | |
} | |
} | |
function Get-CronPredefined | |
{ | |
return @{ | |
# normal | |
'@minutely' = '* * * * *'; | |
'@hourly' = '0 * * * *'; | |
'@daily' = '0 0 * * *'; | |
'@weekly' = '0 0 * * 0'; | |
'@monthly' = '0 0 1 * *'; | |
'@quarterly' = '0 0 1 1,4,7,10'; | |
'@yearly' = '0 0 1 1 *'; | |
'@annually' = '0 0 1 1 *'; | |
# twice | |
'@semihourly' = '0,30 * * * *'; | |
'@semidaily' = '0 0,12 * * *'; | |
'@semiweekly' = '0 0 * * 0,4'; | |
'@semimonthly' = '0 0 1,15 * *'; | |
'@semiyearly' = '0 0 1 1,6 *'; | |
'@semiannually' = '0 0 1 1,6 *'; | |
} | |
} | |
function Get-CronFieldAliases | |
{ | |
return @{ | |
'Month' = @{ | |
'Jan' = 1; | |
'Feb' = 2; | |
'Mar' = 3; | |
'Apr' = 4; | |
'May' = 5; | |
'Jun' = 6; | |
'Jul' = 7; | |
'Aug' = 8; | |
'Sep' = 9; | |
'Oct' = 10; | |
'Nov' = 11; | |
'Dec' = 12; | |
}; | |
'DayOfWeek' = @{ | |
'Sun' = 0; | |
'Mon' = 1; | |
'Tue' = 2; | |
'Wed' = 3; | |
'Thu' = 4; | |
'Fri' = 5; | |
'Sat' = 6; | |
}; | |
} | |
} | |
function ConvertFrom-CronExpression | |
{ | |
param ( | |
[Parameter(Mandatory=$true)] | |
[ValidateNotNullOrEmpty()] | |
[string] | |
$Expression | |
) | |
$Expression = $Expression.Trim() | |
# check predefineds | |
$predef = Get-CronPredefined | |
if ($null -ne $predef[$Expression]) { | |
$Expression = $predef[$Expression] | |
} | |
# split and check atoms length | |
$atoms = @($Expression -isplit '\s+') | |
if ($atoms.Length -ne 5) { | |
throw "Cron expression should only consist of 5 parts: $($Expression)" | |
} | |
# basic variables | |
$aliasRgx = '(?<tag>[a-z]{3})' | |
# get cron obj and validate atoms | |
$fields = Get-CronFields | |
$constraints = Get-CronFieldConstraints | |
$aliases = Get-CronFieldAliases | |
$cron = @{} | |
for ($i = 0; $i -lt $atoms.Length; $i++) | |
{ | |
$_cronExp = @{ | |
'Range' = $null; | |
'Values' = $null; | |
} | |
$_atom = $atoms[$i] | |
$_field = $fields[$i] | |
$_constraint = $constraints.MinMax[$i] | |
$_aliases = $aliases[$_field] | |
# replace day of week and months with numbers | |
switch ($_field) | |
{ | |
{ $_field -ieq 'month' -or $_field -ieq 'dayofweek' } | |
{ | |
while ($_atom -imatch $aliasRgx) { | |
$_alias = $_aliases[$Matches['tag']] | |
if ($null -eq $_alias) { | |
throw "Invalid $($_field) alias found: $($Matches['tag'])" | |
} | |
$_atom = $_atom -ireplace $Matches['tag'], $_alias | |
$_atom -imatch $aliasRgx | Out-Null | |
} | |
} | |
} | |
# ensure atom is a valid value | |
if (!($_atom -imatch '^[\d|/|*|\-|,]+$')) { | |
throw "Invalid atom character: $($_atom)" | |
} | |
# replace * with min/max constraint | |
$_atom = $_atom -ireplace '\*', ($_constraint -join '-') | |
# parse the atom for either a literal, range, array, or interval | |
# literal | |
if ($_atom -imatch '^\d+$') { | |
$_cronExp.Values = @([int]$_atom) | |
} | |
# range | |
elseif ($_atom -imatch '^(?<min>\d+)\-(?<max>\d+)$') { | |
$_cronExp.Range = @{ 'Min' = [int]($Matches['min'].Trim()); 'Max' = [int]($Matches['max'].Trim()); } | |
} | |
# array | |
elseif ($_atom -imatch '^[\d,]+$') { | |
$_cronExp.Values = [int[]](@($_atom -split ',').Trim()) | |
} | |
# interval | |
elseif ($_atom -imatch '(?<start>(\d+|\*))\/(?<interval>\d+)$') { | |
$start = $Matches['start'] | |
$interval = [int]$Matches['interval'] | |
if ($interval -ieq 0) { | |
$interval = 1 | |
} | |
if ([string]::IsNullOrWhiteSpace($start) -or $start -ieq '*') { | |
$start = 0 | |
} | |
$start = [int]$start | |
$_cronExp.Values = @($start) | |
$next = $start + $interval | |
while ($next -le $_constraint[1]) { | |
$_cronExp.Values += $next | |
$next += $interval | |
} | |
} | |
# error | |
else { | |
throw "Invalid cron atom format found: $($_atom)" | |
} | |
# ensure cron expression values are valid | |
if ($null -ne $_cronExp.Range) { | |
if ($_cronExp.Range.Min -gt $_cronExp.Range.Max) { | |
throw "Min value for $($_field) should not be greater than the max value" | |
} | |
if ($_cronExp.Range.Min -lt $_constraint[0]) { | |
throw "Min value '$($_cronExp.Range.Min)' for $($_field) is invalid, should be greater than/equal to $($_constraint[0])" | |
} | |
if ($_cronExp.Range.Max -gt $_constraint[1]) { | |
throw "Max value '$($_cronExp.Range.Max)' for $($_field) is invalid, should be less than/equal to $($_constraint[1])" | |
} | |
} | |
if ($null -ne $_cronExp.Values) { | |
$_cronExp.Values | ForEach-Object { | |
if ($_ -lt $_constraint[0] -or $_ -gt $_constraint[1]) { | |
throw "Value '$($_)' for $($_field) is invalid, should be between $($_constraint[0]) and $($_constraint[1])" | |
} | |
} | |
} | |
# assign value | |
$cron[$_field] = $_cronExp | |
} | |
# post validation for month/days in month | |
if ($null -ne $cron['Month'].Values -and $null -ne $cron['DayOfMonth'].Values) | |
{ | |
foreach ($mon in $cron['Month'].Values) { | |
foreach ($day in $cron['DayOfMonth'].Values) { | |
if ($day -gt $constraints.DaysInMonths[$mon - 1]) { | |
throw "$($constraints.Months[$mon - 1]) only has $($constraints.DaysInMonths[$mon - 1]) days, but $($day) was supplied" | |
} | |
} | |
} | |
} | |
# return the parsed cron expression | |
return $cron | |
} | |
function Test-RangeAndValue($AtomContraint, $NowValue) { | |
if ($null -ne $AtomContraint.Range) { | |
if ($NowValue -lt $AtomContraint.Range.Min -or $NowValue -gt $AtomContraint.Range.Max) { | |
return $false | |
} | |
} | |
elseif ($AtomContraint.Values -inotcontains $NowValue) { | |
return $false | |
} | |
return $true | |
} | |
# current time | |
if ($null -eq $DateTime) { | |
$DateTime = [datetime]::Now | |
} | |
# convert the expression | |
$Atoms = ConvertFrom-CronExpression -Expression $Expression | |
# check day of month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfMonth -NowValue $DateTime.Day)) { | |
return $false | |
} | |
# check day of week | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.DayOfWeek -NowValue ([int]$DateTime.DayOfWeek))) { | |
return $false | |
} | |
# check month | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Month -NowValue $DateTime.Month)) { | |
return $false | |
} | |
# check hour | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Hour -NowValue $DateTime.Hour)) { | |
return $false | |
} | |
# check minute | |
if (!(Test-RangeAndValue -AtomContraint $Atoms.Minute -NowValue $DateTime.Minute)) { | |
return $false | |
} | |
# date is valid | |
return $true |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment