// Cron expression parser, scheduler, and describer export type FieldType = 'second' | 'minute' | 'hour' | 'dom' | 'month' | 'dow'; export interface CronFieldConfig { min: number; max: number; label: string; shortLabel: string; names?: readonly string[]; aliases?: Record; } export const FIELD_CONFIGS: Record = { second: { min: 0, max: 59, label: 'Second', shortLabel: 'SEC' }, minute: { min: 0, max: 59, label: 'Minute', shortLabel: 'MIN' }, hour: { min: 0, max: 23, label: 'Hour', shortLabel: 'HOUR' }, dom: { min: 1, max: 31, label: 'Day of Month', shortLabel: 'DOM' }, month: { min: 1, max: 12, label: 'Month', shortLabel: 'MON', names: ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'], aliases: { JAN:1,FEB:2,MAR:3,APR:4,MAY:5,JUN:6,JUL:7,AUG:8,SEP:9,OCT:10,NOV:11,DEC:12 }, }, dow: { min: 0, max: 6, label: 'Day of Week', shortLabel: 'DOW', names: ['SUN','MON','TUE','WED','THU','FRI','SAT'], aliases: { SUN:0,MON:1,TUE:2,WED:3,THU:4,FRI:5,SAT:6 }, }, }; export const MONTH_FULL_NAMES = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; export const DOW_FULL_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; export const MONTH_SHORT_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']; export const DOW_SHORT_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT']; export interface ParsedCronField { raw: string; values: Set; isWildcard: boolean; } export interface ParsedCron { hasSeconds: boolean; fields: { second?: ParsedCronField; minute: ParsedCronField; hour: ParsedCronField; dom: ParsedCronField; month: ParsedCronField; dow: ParsedCronField; }; } export interface CronFields { second?: string; minute: string; hour: string; dom: string; month: string; dow: string; hasSeconds: boolean; } // ── Special expressions ─────────────────────────────────────────────────────── const SPECIAL_EXPRESSIONS: Record = { '@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *', '@monthly': '0 0 1 * *', '@weekly': '0 0 * * 0', '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *', '@reboot': null, }; // ── Low-level field parser ──────────────────────────────────────────────────── function resolveAlias(val: string, config: CronFieldConfig): number { const n = parseInt(val, 10); if (!isNaN(n)) return n; if (config.aliases) { const upper = val.toUpperCase(); if (upper in config.aliases) return config.aliases[upper]; } return NaN; } function parsePart(part: string, config: CronFieldConfig, values: Set): boolean { // Step: */5 or 0-30/5 or 5/15 const stepMatch = part.match(/^(.+)\/(\d+)$/); if (stepMatch) { const step = parseInt(stepMatch[2], 10); if (isNaN(step) || step < 1) return false; let start: number, end: number; if (stepMatch[1] === '*') { start = config.min; end = config.max; } else { const rm = stepMatch[1].match(/^(.+)-(.+)$/); if (rm) { start = resolveAlias(rm[1], config); end = resolveAlias(rm[2], config); } else { start = resolveAlias(stepMatch[1], config); end = config.max; } } if (isNaN(start) || isNaN(end) || start < config.min || end > config.max) return false; for (let i = start; i <= end; i += step) values.add(i); return true; } // Range: 1-5 const rangeMatch = part.match(/^(.+)-(.+)$/); if (rangeMatch) { const start = resolveAlias(rangeMatch[1], config); const end = resolveAlias(rangeMatch[2], config); if (isNaN(start) || isNaN(end) || start > end || start < config.min || end > config.max) return false; for (let i = start; i <= end; i++) values.add(i); return true; } // Single const n = resolveAlias(part, config); if (isNaN(n)) return false; const adjusted = (config === FIELD_CONFIGS.dow && n === 7) ? 0 : n; if (adjusted < config.min || adjusted > config.max) return false; values.add(adjusted); return true; } export function parseField(expr: string, config: CronFieldConfig): ParsedCronField | null { if (!expr) return null; const values = new Set(); if (expr === '*') { for (let i = config.min; i <= config.max; i++) values.add(i); return { raw: expr, values, isWildcard: true }; } for (const part of expr.split(',')) { if (!parsePart(part.trim(), config, values)) return null; } return { raw: expr, values, isWildcard: false }; } // ── Expression parser ───────────────────────────────────────────────────────── export function parseCronExpression(expr: string): ParsedCron | null { expr = expr.trim(); const lower = expr.toLowerCase(); if (lower.startsWith('@')) { const resolved = SPECIAL_EXPRESSIONS[lower]; if (resolved === undefined) return null; if (resolved === null) return null; expr = resolved; } const parts = expr.split(/\s+/); if (parts.length < 5 || parts.length > 6) return null; const hasSeconds = parts.length === 6; const o = hasSeconds ? 1 : 0; let secondField: ParsedCronField | undefined; if (hasSeconds) { const f = parseField(parts[0], FIELD_CONFIGS.second); if (!f) return null; secondField = f; } const minute = parseField(parts[o + 0], FIELD_CONFIGS.minute); const hour = parseField(parts[o + 1], FIELD_CONFIGS.hour); const dom = parseField(parts[o + 2], FIELD_CONFIGS.dom); const month = parseField(parts[o + 3], FIELD_CONFIGS.month); const dow = parseField(parts[o + 4], FIELD_CONFIGS.dow); if (!minute || !hour || !dom || !month || !dow) return null; return { hasSeconds, fields: { second: secondField, minute, hour, dom, month, dow } }; } // ── Field value reconstruction ──────────────────────────────────────────────── export function rebuildFieldFromValues(values: Set, config: CronFieldConfig): string { const sorted = [...values].sort((a, b) => a - b); if (sorted.length === 0) return '*'; if (sorted.length === config.max - config.min + 1) return '*'; // Regular step from min → */N if (sorted.length > 1) { const step = sorted[1] - sorted[0]; if (step > 0 && sorted.every((v, i) => v === sorted[0] + i * step)) { if (sorted[0] === config.min) return `*/${step}`; return `${sorted[0]}-${sorted[sorted.length - 1]}/${step}`; } // Consecutive range if (sorted.every((v, i) => i === 0 || v === sorted[i - 1] + 1)) { return `${sorted[0]}-${sorted[sorted.length - 1]}`; } } return sorted.join(','); } // ── Split / build ───────────────────────────────────────────────────────────── export function splitCronFields(expr: string): CronFields | null { const lower = expr.trim().toLowerCase(); const resolved = SPECIAL_EXPRESSIONS[lower]; if (resolved !== undefined) { if (resolved === null) return null; expr = resolved; } const parts = expr.trim().split(/\s+/); if (parts.length === 5) { return { minute: parts[0], hour: parts[1], dom: parts[2], month: parts[3], dow: parts[4], hasSeconds: false }; } if (parts.length === 6) { return { second: parts[0], minute: parts[1], hour: parts[2], dom: parts[3], month: parts[4], dow: parts[5], hasSeconds: true }; } return null; } export function buildCronExpression(fields: CronFields): string { const base = `${fields.minute} ${fields.hour} ${fields.dom} ${fields.month} ${fields.dow}`; return fields.hasSeconds && fields.second ? `${fields.second} ${base}` : base; } // ── Day matching ────────────────────────────────────────────────────────────── function checkDay(d: Date, parsed: ParsedCron): boolean { const domWild = parsed.fields.dom.isWildcard; const dowWild = parsed.fields.dow.isWildcard; if (domWild && dowWild) return true; if (domWild) return parsed.fields.dow.values.has(d.getDay()); if (dowWild) return parsed.fields.dom.values.has(d.getDate()); return parsed.fields.dom.values.has(d.getDate()) || parsed.fields.dow.values.has(d.getDay()); } // ── Smart advance algorithm ─────────────────────────────────────────────────── function advanceToNext(date: Date, parsed: ParsedCron): Date | null { const d = new Date(date); const maxDate = new Date(date.getTime() + 5 * 366 * 24 * 60 * 60 * 1000); let guard = 0; while (d < maxDate && guard++ < 200_000) { // Month const m = d.getMonth() + 1; if (!parsed.fields.month.values.has(m)) { const sorted = [...parsed.fields.month.values].sort((a, b) => a - b); const next = sorted.find(v => v > m); if (next !== undefined) { d.setMonth(next - 1, 1); } else { d.setFullYear(d.getFullYear() + 1, sorted[0] - 1, 1); } d.setHours(0, 0, 0, 0); continue; } // Day if (!checkDay(d, parsed)) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0); continue; } // Hour const h = d.getHours(); const sortedH = [...parsed.fields.hour.values].sort((a, b) => a - b); if (!parsed.fields.hour.values.has(h)) { const next = sortedH.find(v => v > h); if (next !== undefined) { d.setHours(next, 0, 0, 0); } else { d.setDate(d.getDate() + 1); d.setHours(sortedH[0], 0, 0, 0); } continue; } // Minute const min = d.getMinutes(); const sortedM = [...parsed.fields.minute.values].sort((a, b) => a - b); if (!parsed.fields.minute.values.has(min)) { const next = sortedM.find(v => v > min); if (next !== undefined) { d.setMinutes(next, 0, 0); } else { const nextH = sortedH.find(v => v > h); if (nextH !== undefined) { d.setHours(nextH, sortedM[0], 0, 0); } else { d.setDate(d.getDate() + 1); d.setHours(sortedH[0], sortedM[0], 0, 0); } } continue; } return new Date(d); } return null; } export function getNextOccurrences( expr: string, count: number = 8, from: Date = new Date(), ): Date[] { const parsed = parseCronExpression(expr); if (!parsed) return []; const results: Date[] = []; let current = new Date(from); current.setSeconds(0, 0); current.setTime(current.getTime() + 60_000); // start from next minute for (let i = 0; i < count; i++) { const next = advanceToNext(current, parsed); if (!next) break; results.push(next); current = new Date(next.getTime() + 60_000); } return results; } // ── Human-readable description ──────────────────────────────────────────────── function isStepRaw(raw: string): boolean { return /^(\*|\d+)\/\d+$/.test(raw); } function stepValue(raw: string): number | null { const m = raw.match(/\/(\d+)$/); return m ? parseInt(m[1], 10) : null; } function ordinal(n: number): string { const s = ['th', 'st', 'nd', 'rd']; const v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); } function formatTime12(hour: number, minute: number): string { const ampm = hour < 12 ? 'AM' : 'PM'; const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; return `${h}:${String(minute).padStart(2, '0')} ${ampm}`; } function formatHour(h: number): string { const ampm = h < 12 ? 'AM' : 'PM'; const d = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${d}:00 ${ampm}`; } function formatDowList(vals: number[]): string { if (vals.length === 1) return DOW_FULL_NAMES[vals[0]]; if (vals.length === 7) return 'every day'; return vals.map(v => DOW_FULL_NAMES[v]).join(', '); } export function describeCronExpression(expr: string): string { const lower = expr.trim().toLowerCase(); const specialDescs: Record = { '@yearly': 'Every year on January 1st at midnight', '@annually': 'Every year on January 1st at midnight', '@monthly': 'Every month on the 1st at midnight', '@weekly': 'Every week on Sunday at midnight', '@daily': 'Every day at midnight', '@midnight': 'Every day at midnight', '@hourly': 'Every hour at :00', '@reboot': 'Once at system reboot', }; if (lower in specialDescs) return specialDescs[lower]; const parsed = parseCronExpression(expr); if (!parsed) return 'Invalid cron expression'; const { fields } = parsed; const mVals = [...fields.minute.values].sort((a, b) => a - b); const hVals = [...fields.hour.values].sort((a, b) => a - b); const domVals = [...fields.dom.values].sort((a, b) => a - b); const monVals = [...fields.month.values].sort((a, b) => a - b); const dowVals = [...fields.dow.values].sort((a, b) => a - b); const mWild = fields.minute.isWildcard; const hWild = fields.hour.isWildcard; const domWild = fields.dom.isWildcard; const monWild = fields.month.isWildcard; const dowWild = fields.dow.isWildcard; // Time let when = ''; if (mWild && hWild) { when = 'Every minute'; } else if (hWild && isStepRaw(fields.minute.raw)) { const s = stepValue(fields.minute.raw); when = s === 1 ? 'Every minute' : `Every ${s} minutes`; } else if (mWild && isStepRaw(fields.hour.raw)) { const s = stepValue(fields.hour.raw); when = s === 1 ? 'Every hour' : `Every ${s} hours`; } else if (!mWild && hWild) { if (isStepRaw(fields.minute.raw)) { const s = stepValue(fields.minute.raw); when = `Every ${s} minutes`; } else if (mVals.length === 1 && mVals[0] === 0) { when = 'Every hour at :00'; } else if (mVals.length === 1) { when = `Every hour at :${String(mVals[0]).padStart(2, '0')}`; } else { when = `Every hour at minutes ${mVals.join(', ')}`; } } else if (mWild && !hWild) { if (hVals.length === 1) when = `Every minute of ${formatHour(hVals[0])}`; else when = `Every minute of hours ${hVals.join(', ')}`; } else { if (hVals.length === 1 && mVals.length === 1) { when = `At ${formatTime12(hVals[0], mVals[0])}`; } else if (hVals.length === 1) { when = `${formatHour(hVals[0])}, at minutes ${mVals.join(', ')}`; } else if (mVals.length === 1 && mVals[0] === 0) { when = `At ${hVals.map(formatHour).join(' and ')}`; } else { when = `At hours ${hVals.join(', ')}, minutes ${mVals.join(', ')}`; } } // Day let day = ''; if (!domWild || !dowWild) { if (!domWild && !dowWild) { day = `on day ${domVals.map(ordinal).join(', ')} or ${formatDowList(dowVals)}`; } else if (!domWild) { day = domVals.length === 1 ? `on the ${ordinal(domVals[0])}` : `on days ${domVals.map(ordinal).join(', ')}`; } else { const isWeekdays = dowVals.length === 5 && [1,2,3,4,5].every(v => dowVals.includes(v)); const isWeekends = dowVals.length === 2 && dowVals.includes(0) && dowVals.includes(6); if (isWeekdays) day = 'on weekdays'; else if (isWeekends) day = 'on weekends'; else day = `on ${formatDowList(dowVals)}`; } } // Month let month = ''; if (!monWild) { month = `in ${monVals.map(v => MONTH_FULL_NAMES[v - 1]).join(', ')}`; } let result = when; if (day) result += `, ${day}`; if (month) result += `, ${month}`; return result; } export function validateCronExpression(expr: string): { valid: boolean; error?: string } { const parsed = parseCronExpression(expr); if (!parsed) return { valid: false, error: 'Invalid cron expression' }; return { valid: true }; } export function validateCronField(value: string, type: FieldType): { valid: boolean; error?: string } { if (!value.trim()) return { valid: false, error: 'Required' }; const field = parseField(value, FIELD_CONFIGS[type]); if (!field) return { valid: false, error: `Invalid ${type} expression` }; return { valid: true }; }