Private/CronParser.ps1
<#
.SYNOPSIS Provides a list of cron expression fields. .DESCRIPTION This function returns an array of strings representing the different fields in a cron expression. These fields include 'Minute', 'Hour', 'DayOfMonth', 'Month', and 'DayOfWeek'. .OUTPUTS Returns an array of strings representing cron expression fields. .NOTES This is an internal function and may change in future releases of Pode. #> function Get-PodeCronField { [CmdletBinding()] [OutputType([string[]])] param() return [string[]]@( 'Minute', 'Hour', 'DayOfMonth', 'Month', 'DayOfWeek' ) } <# .SYNOPSIS Provides constraints and information for cron expression fields. .DESCRIPTION This function returns a hashtable containing constraints and information for various cron expression fields. It includes details such as valid ranges for minutes, hours, days of the month, months, and days of the week. .OUTPUTS Returns a hashtable with constraints and information for cron expression fields. .NOTES This is an internal function and may change in future releases of Pode. #> function Get-PodeCronFieldConstraint { [CmdletBinding()] [OutputType([hashtable])] param() 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-PodeCronPredefined { 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 '@twice-hourly' = '0,30 * * * *' '@twice-daily' = '0 0,12 * * *' '@twice-weekly' = '0 0 * * 0,4' '@twice-monthly' = '0 0 1,15 * *' '@twice-yearly' = '0 0 1 1,6 *' '@twice-annually' = '0 0 1 1,6 *' } } <# .SYNOPSIS Provides aliases for cron expression fields. .DESCRIPTION This function returns a hashtable containing aliases for cron expression fields. It includes mappings for month abbreviations (e.g., 'Jan' to 1) and day of the week abbreviations (e.g., 'Sun' to 0). .OUTPUTS Returns a hashtable with aliases for cron expression fields. .NOTES This is an internal function and may change in future releases of Pode. #> function Get-PodeCronFieldAlias { [CmdletBinding()] [OutputType([hashtable])] param() 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 } } } <# .SYNOPSIS Converts a Pode-style cron expression into a hashtable representation. .DESCRIPTION This function takes an array of Pode-style cron expressions and converts them into a hashtable format. Each hashtable represents a cron expression with its individual components. .PARAMETER Expression An array of Pode-style cron expressions to convert. .OUTPUTS A hashtable representing the cron expression with the following keys: - 'Minute' - 'Hour' - 'DayOfMonth' - 'Month' - 'DayOfWeek' .NOTES This is an internal function and may change in future releases of Pode. #> function ConvertFrom-PodeCronExpression { [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string[]] $Expression ) $cronList = @() foreach ($item in $Expression) { if ([string]::IsNullOrEmpty($item)) { continue } $item = $item.Trim() # check predefineds $predef = Get-PodeCronPredefined if (!(Test-PodeIsEmpty $predef[$item])) { $item = $predef[$item] } # split and check atoms length $atoms = @($item -isplit '\s+') if ($atoms.Length -ne 5) { # Cron expression should only consist of 5 parts throw ($PodeLocale.cronExpressionInvalidExceptionMessage -f $Expression) } # basic variables $aliasRgx = '(?<tag>[a-z]{3})' # get cron obj and validate atoms $fields = Get-PodeCronField $constraints = Get-PodeCronFieldConstraint $aliases = Get-PodeCronFieldAlias $cron = @{} for ($i = 0; $i -lt $atoms.Length; $i++) { $_cronExp = @{ Range = $null Values = $null Constraints = $null Random = $false WildCard = $false } $_atom = $atoms[$i] $_field = $fields[$i] $_constraint = $constraints.MinMax[$i] $_aliases = $aliases[$_field] # replace day of week and months with numbers if (@('month', 'dayofweek') -icontains $_field) { while ($_atom -imatch $aliasRgx) { $_alias = $_aliases[$Matches['tag']] if ($null -eq $_alias) { # Invalid $($_field) alias found: $($Matches['tag']) throw ($PodeLocale.invalidAliasFoundExceptionMessage -f $_field, $Matches['tag']) } $_atom = $_atom -ireplace $Matches['tag'], $_alias $null = $_atom -imatch $aliasRgx } } # ensure atom is a valid value if (!($_atom -imatch '^[\d|/|*|\-|,r]+$')) { # Invalid atom character throw ($PodeLocale.invalidAtomCharacterExceptionMessage -f $_atom) } # replace * with min/max constraint if ($_atom -ieq '*') { $_cronExp.WildCard = $true $_atom = ($_constraint -join '-') } # parse the atom for either a literal, range, array, or interval # literal if ($_atom -imatch '^(\d+|r)$') { # check if it's random if ($_atom -ieq 'r') { $_cronExp.Values = @(Get-Random -Minimum $_constraint[0] -Maximum ($_constraint[1] + 1)) $_cronExp.Random = $true } else { $_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+|r))$') { $start = $Matches['start'] $interval = $Matches['interval'] if ($interval -ieq '0') { $interval = '1' } if ([string]::IsNullOrWhiteSpace($start) -or ($start -ieq '*')) { $start = '0' } # set the initial trigger value $_cronExp.Values = @([int]$start) # check if it's random if ($interval -ieq 'r') { $_cronExp.Random = $true } else { # loop to get all next values $next = [int]$start + [int]$interval while ($next -le $_constraint[1]) { $_cronExp.Values += $next $next += [int]$interval } } } # error else { # Invalid cron atom format found throw ($PodeLocale.invalidCronAtomFormatExceptionMessage -f $_atom) } # ensure cron expression values are valid if ($null -ne $_cronExp.Range) { if ($_cronExp.Range.Min -gt $_cronExp.Range.Max) { # Min value should not be greater than the max value throw ($PodeLocale.minValueGreaterThanMaxExceptionMessage -f $_field) } if ($_cronExp.Range.Min -lt $_constraint[0]) { # Min value for $($_field) is invalid, should be greater than/equal throw ($PodeLocale.minValueInvalidExceptionMessage -f $_cronExp.Range.Min, $_field, $_constraint[0]) } if ($_cronExp.Range.Max -gt $_constraint[1]) { # Max value for $($_field) is invalid, should be greater than/equal throw ($PodeLocale.maxValueInvalidExceptionMessage -f $_cronExp.Range.Max, $_field, $_constraint[1]) } } if ($null -ne $_cronExp.Values) { $_cronExp.Values | ForEach-Object { if ($_ -lt $_constraint[0] -or $_ -gt $_constraint[1]) { # Value is invalid, should be between throw ($PodeLocale.valueOutOfRangeExceptionMessage -f $value, $_field, $_constraint[0], $_constraint[1]) } } } # assign value $_cronExp.Constraints = $_constraint $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]) { # $($constraints.Months[$mon - 1]) only has $($constraints.DaysInMonths[$mon - 1]) days, but $($day) was supplied throw ($PodeLocale.daysInMonthExceededExceptionMessage -f $constraints.Months[$mon - 1], $constraints.DaysInMonths[$mon - 1], $day) } } } } # flag if this cron contains a random atom $cron['Random'] = (($cron.Values | Where-Object { $_.Random } | Measure-Object).Count -gt 0) # add the cron to the list $cronList += $cron } # return the cronlist return $cronList } function Reset-PodeRandomCronExpressions { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expressions ) return @(@($Expressions) | ForEach-Object { Reset-PodeRandomCronExpression -Expression $_ }) } function Reset-PodeRandomCronExpression { param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expression ) function Reset-Atom($Atom) { if (!$Atom.Random) { return $Atom } if ($Atom.Random) { $Atom.Values = @(Get-Random -Minimum $Atom.Constraints[0] -Maximum ($Atom.Constraints[1] + 1)) } return $Atom } if (!$Expression.Random) { return $Expression } $Expression.Minute = (Reset-Atom -Atom $Expression.Minute) $Expression.Hour = (Reset-Atom -Atom $Expression.Hour) $Expression.DayOfMonth = (Reset-Atom -Atom $Expression.DayOfMonth) $Expression.Month = (Reset-Atom -Atom $Expression.Month) $Expression.DayOfWeek = (Reset-Atom -Atom $Expression.DayOfWeek) return $Expression } function Test-PodeCronExpressions { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expressions, [Parameter()] $DateTime = $null ) return ((@($Expressions) | Where-Object { Test-PodeCronExpression -Expression $_ -DateTime $DateTime } | Measure-Object).Count -gt 0) } function Test-PodeCronExpression { param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expression, [Parameter()] $DateTime = $null ) function Test-RangeAndValue($AtomContraint, $NowValue) { if ($null -ne $AtomContraint.Range) { return (!(($NowValue -lt $AtomContraint.Range.Min) -or ($NowValue -gt $AtomContraint.Range.Max))) } return ($AtomContraint.Values -icontains $NowValue) } # current time if ($null -eq $DateTime) { $DateTime = [datetime]::Now } # check day of month if (!(Test-RangeAndValue -AtomContraint $Expression.DayOfMonth -NowValue $DateTime.Day)) { return $false } # check day of week if (!(Test-RangeAndValue -AtomContraint $Expression.DayOfWeek -NowValue ([int]$DateTime.DayOfWeek))) { return $false } # check month if (!(Test-RangeAndValue -AtomContraint $Expression.Month -NowValue $DateTime.Month)) { return $false } # check hour if (!(Test-RangeAndValue -AtomContraint $Expression.Hour -NowValue $DateTime.Hour)) { return $false } # check minute if (!(Test-RangeAndValue -AtomContraint $Expression.Minute -NowValue $DateTime.Minute)) { return $false } # date is valid return $true } function Get-PodeCronNextEarliestTrigger { param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expressions, [Parameter()] $StartTime = $null, [Parameter()] $EndTime = $null ) return (@($Expressions) | Foreach-Object { Get-PodeCronNextTrigger -Expression $_ -StartTime $StartTime -EndTime $EndTime } | Where-Object { $null -ne $_ } | Sort-Object | Select-Object -First 1) } function Get-PodeCronNextTrigger { param( [Parameter(Mandatory = $true)] [ValidateNotNull()] $Expression, [Parameter()] $StartTime = $null, [Parameter()] $EndTime = $null ) # start from the current time, if a start time not defined if ($null -eq $StartTime) { $StartTime = [datetime]::Now } $StartTime = $StartTime.AddMinutes(1) # the next time to trigger $NextTime = [datetime]::new($StartTime.Year, $StartTime.Month, $StartTime.Day, $StartTime.Hour, $StartTime.Minute, 0) # first, is the current time valid? if (Test-PodeCronExpression -Expression $Expression -DateTime $NextTime) { return $NextTime } # functions for getting the closest value function Get-ClosestValue($AtomContraint, $NowValue) { $_values = $AtomContraint.Values if ($null -eq $_values) { $_values = ($AtomContraint.Range.Min..$AtomContraint.Range.Max) } if (($_values.Length -eq 1) -or ($_values[-1] -lt $NowValue) -or ($_values[0] -gt $NowValue)) { return $_values[0] } return ($_values -ge $NowValue)[0] } # loop until we get a date while ($true) { # check the minute if (!$Expression.Minute.WildCard) { $minute = Get-ClosestValue -AtomContraint $Expression.Minute -NowValue $NextTime.Minute if ($minute -lt $NextTime.Minute) { $NextTime = $NextTime.AddHours(1) } $NextTime = $NextTime.AddMinutes($minute - $NextTime.Minute) } # check hour if (!$Expression.Hour.WildCard) { $hour = Get-ClosestValue -AtomContraint $Expression.Hour -NowValue $NextTime.Hour if ($hour -lt $NextTime.Hour) { $NextTime = $NextTime.AddDays(1) } $_hour = $NextTime.Hour $NextTime = $NextTime.AddHours($hour - $NextTime.Hour) if ($_hour -ne $hour) { $NextTime = [datetime]::new($NextTime.Year, $NextTime.Month, $NextTime.Day, $NextTime.Hour, 0, 0) continue } } # check day if (!$Expression.DayOfMonth.WildCard) { $day = Get-ClosestValue -AtomContraint $Expression.DayOfMonth -NowValue $NextTime.Day if (($day -lt $NextTime.Day) -or ($day -gt [datetime]::DaysInMonth($NextTime.Year, $NextTime.Month))) { $NextTime = $NextTime.AddMonths(1) } if ($day -gt [datetime]::DaysInMonth($NextTime.Year, $NextTime.Month)) { $NextTime = [datetime]::new($NextTime.Year, $NextTime.Month, 1, 0, 0, 0) continue } $_day = $NextTime.Day $NextTime = $NextTime.AddDays($day - $NextTime.Day) if ($_day -ne $day) { $NextTime = [datetime]::new($NextTime.Year, $NextTime.Month, $NextTime.Day, 0, 0, 0) continue } } # check month if (!$Expression.Month.WildCard) { $month = Get-ClosestValue -AtomContraint $Expression.Month -NowValue $NextTime.Month if ($month -lt $NextTime.Month) { $NextTime = $NextTime.AddYears(1) } $_month = $NextTime.Month $NextTime = $NextTime.AddMonths($month - $NextTime.Month) if ($_month -ne $month) { $NextTime = [datetime]::new($NextTime.Year, $NextTime.Month, 1, 0, 0, 0) continue } } # check day of week if (!$Expression.DayOfWeek.WildCard) { $doweek = Get-ClosestValue -AtomContraint $Expression.DayOfWeek -NowValue $NextTime.DayOfWeek $_doweek = $NextTime.DayOfWeek if ($doweek -lt $NextTime.DayOfWeek) { $NextTime = $NextTime.AddDays(7 - ($NextTime.DayOfWeek - $doweek)) } elseif ($doweek -gt $NextTime.DayOfWeek) { $NextTime = $NextTime.AddDays($doweek - $NextTime.DayOfWeek) } if ($_doweek -ne $doweek) { $NextTime = [datetime]::new($NextTime.Year, $NextTime.Month, $NextTime.Day, 0, 0, 0) continue } } break } # before we return, make sure the time is valid if (!(Test-PodeCronExpression -Expression $Expression -DateTime $NextTime)) { throw ($PodeLocale.nextTriggerCalculationErrorExceptionMessage -f $NextTime) #"Looks like something went wrong trying to calculate the next trigger datetime: $($NextTime)" } # if before the start or after end then return null if (($NextTime -lt $StartTime) -or (($null -ne $EndTime) -and ($NextTime -gt $EndTime))) { return $null } return $NextTime } |