mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
feat(schedule-trigger): enhance cron parser with mature library and comprehensive testing (#26002)
This commit is contained in:
@ -1,229 +1,84 @@
|
||||
const matchesField = (value: number, pattern: string, min: number, max: number): boolean => {
|
||||
if (pattern === '*') return true
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
|
||||
if (pattern.includes(','))
|
||||
return pattern.split(',').some(p => matchesField(value, p.trim(), min, max))
|
||||
// Convert a UTC date from cron-parser to user timezone representation
|
||||
// This ensures consistency with other execution time calculations
|
||||
const convertToUserTimezoneRepresentation = (utcDate: Date, timezone: string): Date => {
|
||||
// Get the time string in the target timezone
|
||||
const userTimeStr = utcDate.toLocaleString('en-CA', {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, month, day] = dateStr.split('-').map(Number)
|
||||
const [hour, minute, second] = timeStr.split(':').map(Number)
|
||||
|
||||
if (pattern.includes('/')) {
|
||||
const [range, step] = pattern.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue)) return false
|
||||
|
||||
if (range === '*') {
|
||||
return value % stepValue === min % stepValue
|
||||
}
|
||||
else {
|
||||
const rangeStart = Number.parseInt(range, 10)
|
||||
if (Number.isNaN(rangeStart)) return false
|
||||
return value >= rangeStart && (value - rangeStart) % stepValue === 0
|
||||
}
|
||||
}
|
||||
|
||||
if (pattern.includes('-')) {
|
||||
const [start, end] = pattern.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return false
|
||||
return value >= start && value <= end
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(pattern, 10)
|
||||
if (Number.isNaN(numValue)) return false
|
||||
return value === numValue
|
||||
}
|
||||
|
||||
const expandCronField = (field: string, min: number, max: number): number[] => {
|
||||
if (field === '*')
|
||||
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
|
||||
|
||||
if (field.includes(','))
|
||||
return field.split(',').flatMap(p => expandCronField(p.trim(), min, max))
|
||||
|
||||
if (field.includes('/')) {
|
||||
const [range, step] = field.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue)) return []
|
||||
|
||||
const baseValues = range === '*' ? [min] : expandCronField(range, min, max)
|
||||
const result: number[] = []
|
||||
|
||||
for (let start = baseValues[0]; start <= max; start += stepValue) {
|
||||
if (start >= min && start <= max)
|
||||
result.push(start)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return []
|
||||
|
||||
const result: number[] = []
|
||||
for (let i = start; i <= end && i <= max; i++)
|
||||
if (i >= min) result.push(i)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(field, 10)
|
||||
return !Number.isNaN(numValue) && numValue >= min && numValue <= max ? [numValue] : []
|
||||
}
|
||||
|
||||
const matchesCron = (
|
||||
date: Date,
|
||||
minute: string,
|
||||
hour: string,
|
||||
dayOfMonth: string,
|
||||
month: string,
|
||||
dayOfWeek: string,
|
||||
): boolean => {
|
||||
const currentMinute = date.getMinutes()
|
||||
const currentHour = date.getHours()
|
||||
const currentDay = date.getDate()
|
||||
const currentMonth = date.getMonth() + 1
|
||||
const currentDayOfWeek = date.getDay()
|
||||
|
||||
// Basic time matching
|
||||
if (!matchesField(currentMinute, minute, 0, 59)) return false
|
||||
if (!matchesField(currentHour, hour, 0, 23)) return false
|
||||
if (!matchesField(currentMonth, month, 1, 12)) return false
|
||||
|
||||
// Day matching logic: if both dayOfMonth and dayOfWeek are specified (not *),
|
||||
// the cron should match if EITHER condition is true (OR logic)
|
||||
const dayOfMonthSpecified = dayOfMonth !== '*'
|
||||
const dayOfWeekSpecified = dayOfWeek !== '*'
|
||||
|
||||
if (dayOfMonthSpecified && dayOfWeekSpecified) {
|
||||
// If both are specified, match if either matches
|
||||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
|| matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else if (dayOfMonthSpecified) {
|
||||
// Only day of month specified
|
||||
return matchesField(currentDay, dayOfMonth, 1, 31)
|
||||
}
|
||||
else if (dayOfWeekSpecified) {
|
||||
// Only day of week specified
|
||||
return matchesField(currentDayOfWeek, dayOfWeek, 0, 6)
|
||||
}
|
||||
else {
|
||||
// Both are *, matches any day
|
||||
return true
|
||||
}
|
||||
// Create a new Date object representing this time as "local" time
|
||||
// This matches the behavior expected by the execution-time-calculator
|
||||
return new Date(year, month - 1, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a cron expression and return the next 5 execution times
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression (minute hour day month dayOfWeek)
|
||||
* @param timezone - IANA timezone identifier (e.g., 'UTC', 'America/New_York')
|
||||
* @returns Array of Date objects representing the next 5 execution times
|
||||
*/
|
||||
export const parseCronExpression = (cronExpression: string, timezone: string = 'UTC'): Date[] => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return []
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5)
|
||||
return []
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return []
|
||||
|
||||
try {
|
||||
const nextTimes: Date[] = []
|
||||
|
||||
// Get user timezone current time - no browser timezone involved
|
||||
const now = new Date()
|
||||
const userTimeStr = now.toLocaleString('en-CA', {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
// Parse the cron expression with timezone support
|
||||
// Use the actual current time for cron-parser to handle properly
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
tz: timezone,
|
||||
})
|
||||
const [dateStr, timeStr] = userTimeStr.split(', ')
|
||||
const [year, monthNum, day] = dateStr.split('-').map(Number)
|
||||
const [nowHour, nowMinute, nowSecond] = timeStr.split(':').map(Number)
|
||||
const userToday = new Date(year, monthNum - 1, day, 0, 0, 0, 0)
|
||||
const userCurrentTime = new Date(year, monthNum - 1, day, nowHour, nowMinute, nowSecond)
|
||||
|
||||
const isMonthlyPattern = dayOfMonth !== '*' && dayOfWeek === '*'
|
||||
const isWeeklyPattern = dayOfMonth === '*' && dayOfWeek !== '*'
|
||||
// Get the next 5 execution times using the take() method
|
||||
const nextCronDates = interval.take(5)
|
||||
|
||||
let searchMonths = 12
|
||||
if (isWeeklyPattern) searchMonths = 3
|
||||
else if (!isMonthlyPattern) searchMonths = 2
|
||||
|
||||
for (let monthOffset = 0; monthOffset < searchMonths && nextTimes.length < 5; monthOffset++) {
|
||||
const checkMonth = new Date(userToday.getFullYear(), userToday.getMonth() + monthOffset, 1)
|
||||
const daysInMonth = new Date(checkMonth.getFullYear(), checkMonth.getMonth() + 1, 0).getDate()
|
||||
|
||||
for (let day = 1; day <= daysInMonth && nextTimes.length < 5; day++) {
|
||||
const checkDate = new Date(checkMonth.getFullYear(), checkMonth.getMonth(), day)
|
||||
|
||||
if (minute !== '*' && hour !== '*') {
|
||||
const minuteValues = expandCronField(minute, 0, 59)
|
||||
const hourValues = expandCronField(hour, 0, 23)
|
||||
|
||||
for (const h of hourValues) {
|
||||
for (const m of minuteValues) {
|
||||
checkDate.setHours(h, m, 0, 0)
|
||||
|
||||
// Only add if execution time is in the future and matches cron pattern
|
||||
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||
nextTimes.push(new Date(checkDate))
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (let h = 0; h < 24 && nextTimes.length < 5; h++) {
|
||||
for (let m = 0; m < 60 && nextTimes.length < 5; m++) {
|
||||
checkDate.setHours(h, m, 0, 0)
|
||||
|
||||
// Only add if execution time is in the future and matches cron pattern
|
||||
if (checkDate > userCurrentTime && matchesCron(checkDate, minute, hour, dayOfMonth, month, dayOfWeek))
|
||||
nextTimes.push(new Date(checkDate))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextTimes.sort((a, b) => a.getTime() - b.getTime()).slice(0, 5)
|
||||
// Convert CronDate objects to Date objects and ensure they represent
|
||||
// the time in user timezone (consistent with execution-time-calculator.ts)
|
||||
return nextCronDates.map((cronDate) => {
|
||||
const utcDate = cronDate.toDate()
|
||||
return convertToUserTimezoneRepresentation(utcDate, timezone)
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Return empty array if parsing fails
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const isValidCronField = (field: string, min: number, max: number): boolean => {
|
||||
if (field === '*') return true
|
||||
|
||||
if (field.includes(','))
|
||||
return field.split(',').every(p => isValidCronField(p.trim(), min, max))
|
||||
|
||||
if (field.includes('/')) {
|
||||
const [range, step] = field.split('/')
|
||||
const stepValue = Number.parseInt(step, 10)
|
||||
if (Number.isNaN(stepValue) || stepValue <= 0) return false
|
||||
|
||||
if (range === '*') return true
|
||||
return isValidCronField(range, min, max)
|
||||
}
|
||||
|
||||
if (field.includes('-')) {
|
||||
const [start, end] = field.split('-').map(p => Number.parseInt(p.trim(), 10))
|
||||
if (Number.isNaN(start) || Number.isNaN(end)) return false
|
||||
return start >= min && end <= max && start <= end
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(field, 10)
|
||||
return !Number.isNaN(numValue) && numValue >= min && numValue <= max
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression format and syntax
|
||||
*
|
||||
* @param cronExpression - Standard 5-field cron expression to validate
|
||||
* @returns boolean indicating if the cron expression is valid
|
||||
*/
|
||||
export const isValidCronExpression = (cronExpression: string): boolean => {
|
||||
if (!cronExpression || cronExpression.trim() === '')
|
||||
return false
|
||||
|
||||
const parts = cronExpression.trim().split(/\s+/)
|
||||
if (parts.length !== 5)
|
||||
|
||||
// Support both 5-field format and predefined expressions
|
||||
if (parts.length !== 5 && !cronExpression.startsWith('@'))
|
||||
return false
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
||||
|
||||
return (
|
||||
isValidCronField(minute, 0, 59)
|
||||
&& isValidCronField(hour, 0, 23)
|
||||
&& isValidCronField(dayOfMonth, 1, 31)
|
||||
&& isValidCronField(month, 1, 12)
|
||||
&& isValidCronField(dayOfWeek, 0, 6)
|
||||
)
|
||||
try {
|
||||
// Use cron-parser to validate the expression
|
||||
CronExpressionParser.parse(cronExpression)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user