Created
August 27, 2025 17:36
-
-
Save jorgegonzalez/1b9c6ab538d1a0a6cabd0765cbfaa249 to your computer and use it in GitHub Desktop.
crontab-guru implementation
This file contains hidden or 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
| /** | |
| * Cron Expression Parser Library | |
| * Converts cron expressions to human-readable descriptions and calculates next execution times | |
| */ | |
| class CronParser { | |
| constructor() { | |
| this.monthNames = [ | |
| null, "January", "February", "March", "April", "May", "June", | |
| "July", "August", "September", "October", "November", "December" | |
| ]; | |
| this.weekdayNames = [ | |
| "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" | |
| ]; | |
| this.monthMap = { | |
| jan: "1", feb: "2", mar: "3", apr: "4", may: "5", jun: "6", | |
| jul: "7", aug: "8", sep: "9", oct: "10", nov: "11", dec: "12" | |
| }; | |
| this.weekdayMap = { | |
| sun: "0", mon: "1", tue: "2", wed: "3", thu: "4", fri: "5", sat: "6" | |
| }; | |
| this.specialStrings = { | |
| "@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", "*", "*", "*", "*"] | |
| }; | |
| } | |
| /** | |
| * Parse a cron expression and return human-readable description | |
| * @param {string} expression - Cron expression (e.g., "0 7,18 * * *") | |
| * @returns {Object} Parsed result with description and next execution time | |
| */ | |
| parse(expression) { | |
| const normalized = this.prenormalize(expression); | |
| const schedule = this.normalize(normalized); | |
| const description = this.describe(normalized); | |
| const nextDate = this.getNextDate(schedule); | |
| return { | |
| expression: expression, | |
| description: description, | |
| schedule: schedule, | |
| nextDate: nextDate, | |
| errors: schedule.errors || null | |
| }; | |
| } | |
| /** | |
| * Pre-normalize the cron expression (handle special strings and named values) | |
| */ | |
| prenormalize(expression) { | |
| const parts = expression.trim().split(/\s+/).filter(p => p); | |
| // Handle @reboot special case | |
| if (parts.length === 1 && parts[0] === "@reboot") { | |
| return { | |
| originalParts: parts, | |
| parts: [], | |
| special: "After rebooting." | |
| }; | |
| } | |
| // Handle other special strings | |
| if (parts.length === 1 && this.specialStrings[parts[0]]) { | |
| const expandedParts = this.specialStrings[parts[0]]; | |
| return this.processParts(expandedParts, parts); | |
| } | |
| return this.processParts(parts, parts); | |
| } | |
| /** | |
| * Process cron parts and replace named months/weekdays | |
| */ | |
| processParts(parts, originalParts) { | |
| const processed = parts.map((part, index) => { | |
| // Replace month names (index 3) | |
| if (index === 3) { | |
| return this.replaceNames(part, this.monthMap); | |
| } | |
| // Replace weekday names (index 4) | |
| if (index === 4) { | |
| return this.replaceNames(part, this.weekdayMap); | |
| } | |
| return part; | |
| }); | |
| return { | |
| originalParts: originalParts, | |
| parts: processed, | |
| daysAnded: (processed[2] && processed[2][0] === "*") || (processed[4] && processed[4][0] === "*") | |
| }; | |
| } | |
| /** | |
| * Replace named values with numeric equivalents | |
| */ | |
| replaceNames(str, map) { | |
| let result = str; | |
| Object.keys(map).forEach(name => { | |
| const regex = new RegExp(`(^|[,-/])${name}($|[,-/])`, 'gi'); | |
| result = result.replace(regex, `$1${map[name]}$2`); | |
| }); | |
| return result; | |
| } | |
| /** | |
| * Normalize and validate the cron expression parts | |
| */ | |
| normalize(prenormalized) { | |
| if (!prenormalized.parts || prenormalized.parts.length === 0) { | |
| return prenormalized.special ? { special: prenormalized.special } : {}; | |
| } | |
| const parts = prenormalized.parts; | |
| const result = { | |
| errors: [], | |
| warnings: [], | |
| daysAnded: prenormalized.daysAnded | |
| }; | |
| if (parts.length !== 5) { | |
| result.errors.push("fields"); | |
| return result; | |
| } | |
| // Parse each field | |
| result.minutes = this.parseField(parts[0], 0, 59, "minutes", result); | |
| result.hours = this.parseField(parts[1], 0, 23, "hours", result); | |
| result.dates = this.parseField(parts[2], 1, 31, "dates", result); | |
| result.months = this.parseField(parts[3], 1, 12, "months", result); | |
| result.weekdays = this.parseWeekdayField(parts[4], result); | |
| // Clean up empty arrays | |
| if (result.errors.length === 0) delete result.errors; | |
| if (result.warnings.length === 0) delete result.warnings; | |
| return result; | |
| } | |
| /** | |
| * Parse a single cron field | |
| */ | |
| parseField(field, min, max, name, result) { | |
| if (!field || field.length === 0) { | |
| result.errors.push(name); | |
| return []; | |
| } | |
| // Replace * with range | |
| field = field.replace(/(^|[,-/])\*($|[,-/])/g, `$1${min}-${max}$2`); | |
| const values = []; | |
| const parts = field.split(','); | |
| for (const part of parts) { | |
| const parsed = this.parsePart(part, min, max); | |
| if (parsed.error) { | |
| result.errors.push(name); | |
| return []; | |
| } | |
| if (parsed.warning) { | |
| result.warnings.push(name); | |
| } | |
| values.push(...parsed.values); | |
| } | |
| // Remove duplicates and sort | |
| return [...new Set(values)].sort((a, b) => a - b); | |
| } | |
| /** | |
| * Parse weekday field (special handling for 7 = Sunday) | |
| */ | |
| parseWeekdayField(field, result) { | |
| const values = this.parseField(field, 0, 7, "weekdays", result); | |
| // Convert 7 to 0 for Sunday | |
| return [...new Set(values.map(v => v === 7 ? 0 : v))].sort((a, b) => a - b); | |
| } | |
| /** | |
| * Parse a single part (number, range, or step) | |
| */ | |
| parsePart(part, min, max) { | |
| const tokens = part.match(/\d+|./g); | |
| if (!tokens) return { error: true, values: [] }; | |
| const nums = tokens.map(t => isNaN(t) ? t : Number(t)); | |
| // Single number | |
| if (nums.length === 1 && Number.isInteger(nums[0])) { | |
| return { values: [nums[0]] }; | |
| } | |
| // Range: n-m | |
| if (nums.length === 3 && nums[1] === '-' && Number.isInteger(nums[0]) && Number.isInteger(nums[2])) { | |
| return { values: this.range(nums[0], nums[2], 1) }; | |
| } | |
| // Step: n/m or */m | |
| if (nums.length === 3 && nums[1] === '/' && Number.isInteger(nums[2])) { | |
| if (Number.isInteger(nums[0])) { | |
| return { | |
| values: this.range(nums[0], max, nums[2]), | |
| warning: true | |
| }; | |
| } | |
| } | |
| // Range with step: n-m/s | |
| if (nums.length === 5 && nums[1] === '-' && nums[3] === '/' && | |
| Number.isInteger(nums[0]) && Number.isInteger(nums[2]) && Number.isInteger(nums[4])) { | |
| return { values: this.range(nums[0], nums[2], nums[4]) }; | |
| } | |
| return { error: true, values: [] }; | |
| } | |
| /** | |
| * Generate a range of numbers | |
| */ | |
| range(start, end, step = 1) { | |
| const result = []; | |
| for (let i = start; i <= end; i += step) { | |
| result.push(i); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Generate human-readable description | |
| */ | |
| describe(prenormalized) { | |
| if (prenormalized.special) { | |
| return prenormalized.special; | |
| } | |
| if (!prenormalized.parts || prenormalized.parts.length !== 5) { | |
| return "Invalid cron expression"; | |
| } | |
| const parts = prenormalized.parts; | |
| // Check if it's a specific time (e.g., "30 14 * * *" = "At 14:30") | |
| if (this.isSpecificTime(parts[0], parts[1])) { | |
| return this.describeSpecificTime(parts); | |
| } | |
| // General description | |
| return this.describeGeneral(parts, prenormalized.daysAnded); | |
| } | |
| /** | |
| * Check if minutes and hours represent a specific time | |
| */ | |
| isSpecificTime(minutes, hours) { | |
| return /^0*\d\d?$/.test(minutes) && /^0*\d\d?$/.test(hours); | |
| } | |
| /** | |
| * Describe a specific time | |
| */ | |
| describeSpecificTime(parts) { | |
| const minute = parts[0].padStart(2, '0'); | |
| const hour = parts[1].padStart(2, '0'); | |
| let description = `At ${hour}:${minute}`; | |
| const dateDesc = this.describeField(parts[2], "day-of-month", null, 31); | |
| const monthDesc = this.describeField(parts[3], "month", this.monthNames, 12); | |
| const weekdayDesc = this.describeField(parts[4], "day-of-week", this.weekdayNames, 7); | |
| if (dateDesc !== "") description += " on " + dateDesc; | |
| if (weekdayDesc !== "") description += " on " + weekdayDesc; | |
| if (monthDesc !== "") description += " in " + monthDesc; | |
| return description.replace(/\s+/g, ' ').trim() + "."; | |
| } | |
| /** | |
| * General description for complex expressions | |
| */ | |
| describeGeneral(parts, daysAnded) { | |
| let description = "At"; | |
| const minuteDesc = this.describeField(parts[0], "minute", null, 59); | |
| const hourDesc = this.describeField(parts[1], "hour", null, 23); | |
| const dateDesc = this.describeField(parts[2], "day-of-month", null, 31); | |
| const monthDesc = this.describeField(parts[3], "month", this.monthNames, 12); | |
| const weekdayDesc = this.describeField(parts[4], "day-of-week", this.weekdayNames, 7); | |
| if (minuteDesc) description += " " + minuteDesc; | |
| if (hourDesc) description += " past " + hourDesc; | |
| if (dateDesc) description += " on " + dateDesc; | |
| if (dateDesc && weekdayDesc) { | |
| description += daysAnded ? " if it's" : " and"; | |
| } | |
| if (weekdayDesc) description += " on " + weekdayDesc; | |
| if (monthDesc) description += " in " + monthDesc; | |
| return description.replace(/\s+/g, ' ').trim() + "."; | |
| } | |
| /** | |
| * Describe a single field | |
| */ | |
| describeField(field, unit, names, max) { | |
| if (field === "*") return ""; | |
| const parts = field.split(','); | |
| const descriptions = parts.map(part => this.describePart(part, unit, names, max)); | |
| return this.joinDescriptions(descriptions); | |
| } | |
| /** | |
| * Describe a single part of a field | |
| */ | |
| describePart(part, unit, names, max) { | |
| // Handle single values | |
| if (/^\d+$/.test(part)) { | |
| const num = parseInt(part); | |
| return names ? names[num] || num : this.ordinal(num); | |
| } | |
| // Handle ranges | |
| if (part.includes('-')) { | |
| const [start, end] = part.split('-').map(Number); | |
| const startName = names ? names[start] || start : start; | |
| const endName = names ? names[end] || end : end; | |
| return `every ${unit} from ${startName} through ${endName}`; | |
| } | |
| // Handle steps | |
| if (part.includes('/')) { | |
| const [range, step] = part.split('/'); | |
| if (range === '*') { | |
| return `every ${this.ordinal(step)} ${unit}`; | |
| } | |
| const [start, end] = range.split('-').map(Number); | |
| const startName = names ? names[start] || start : start; | |
| const endName = names ? names[end] || end : end; | |
| return `every ${this.ordinal(step)} ${unit} from ${startName} through ${endName}`; | |
| } | |
| return part; | |
| } | |
| /** | |
| * Convert number to ordinal (1st, 2nd, 3rd, etc.) | |
| */ | |
| ordinal(num) { | |
| const n = parseInt(num); | |
| const s = n > 20 ? n % 10 : n; | |
| const suffix = s === 1 ? 'st' : s === 2 ? 'nd' : s === 3 ? 'rd' : 'th'; | |
| return n + suffix; | |
| } | |
| /** | |
| * Join field descriptions | |
| */ | |
| joinDescriptions(descriptions) { | |
| if (descriptions.length === 0) return ""; | |
| if (descriptions.length === 1) return descriptions[0]; | |
| if (descriptions.length === 2) return descriptions[0] + " and " + descriptions[1]; | |
| return descriptions.slice(0, -1).join(", ") + ", and " + descriptions[descriptions.length - 1]; | |
| } | |
| /** | |
| * Calculate the next execution date | |
| */ | |
| getNextDate(schedule, fromDate = new Date()) { | |
| if (schedule.errors || schedule.special) return null; | |
| // Round up to next minute | |
| const date = new Date(fromDate); | |
| date.setSeconds(0); | |
| date.setMilliseconds(0); | |
| date.setMinutes(date.getMinutes() + 1); | |
| // Try to find next valid date (max 1000 iterations to prevent infinite loop) | |
| for (let i = 0; i < 1000; i++) { | |
| if (this.dateMatches(date, schedule)) { | |
| return date; | |
| } | |
| // Increment by 1 minute | |
| date.setMinutes(date.getMinutes() + 1); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Check if a date matches the cron schedule | |
| */ | |
| dateMatches(date, schedule) { | |
| const minute = date.getMinutes(); | |
| const hour = date.getHours(); | |
| const dayOfMonth = date.getDate(); | |
| const month = date.getMonth() + 1; | |
| const dayOfWeek = date.getDay(); | |
| if (!schedule.minutes.includes(minute)) return false; | |
| if (!schedule.hours.includes(hour)) return false; | |
| if (!schedule.months.includes(month)) return false; | |
| const dateMatch = schedule.dates.includes(dayOfMonth); | |
| const weekdayMatch = schedule.weekdays.includes(dayOfWeek); | |
| if (schedule.daysAnded) { | |
| return dateMatch && weekdayMatch; | |
| } else { | |
| return dateMatch || weekdayMatch; | |
| } | |
| } | |
| } | |
| // Export for different module systems | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = CronParser; | |
| } else if (typeof define === 'function' && define.amd) { | |
| define([], function() { return CronParser; }); | |
| } else { | |
| window.CronParser = CronParser; | |
| } | |
| // Usage example: | |
| /* | |
| const parser = new CronParser(); | |
| const result = parser.parse("0 7,18 * * *"); | |
| console.log(result.description); // "At 07:00 and 18:00." | |
| console.log(result.nextDate); // Next execution date | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment