Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
PowerShell cron expression parser, to check if a date/time matches a cron expression
<#
.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
@Vinyjones
Copy link

Vinyjones commented Jun 6, 2019

ConvertFrom-CronExpression function is never user

$Expression.Month how it is a thing ?

Something missing, isn't it ?

@Badgerati
Copy link
Author

Badgerati commented Jun 6, 2019

@Vinyjones You're correct, I've fixed it

@Paxilein
Copy link

Paxilein commented Aug 2, 2019

Thank you for this great Script :)
But wouldn't it make more sense to split the expression for DayOfWeek and DayOfMonth so you can check for Day only? I had to split it to run it on specific days of the month without caring about the day in the week....

@Badgerati
Copy link
Author

Badgerati commented Aug 2, 2019

@Paxilein - Hi, and thanks!

You're correct, it would make far more sense. I've split the two apart, and also updated with missing @minutely and @quarterly predefined types.

@Paxilein
Copy link

Paxilein commented Aug 7, 2019

I think quarterly should not include the 8 and the semidaily is more like a semihourly?
'@quarterly' = '0 0 1 1,4,8,7,10'; -> '@quarterly' = '0 0 1 1,4,7,10';
'@semidaily' = '0,12 0 * * *'; -> '@semidaily' = '0 0,12 * * *';

@Badgerati
Copy link
Author

Badgerati commented Aug 9, 2019

Yep, fixed. Cheers

@codykonior
Copy link

codykonior commented Aug 27, 2020

This is neat, what's the license on it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment