// A cron expression in English. Not a parser for running jobs — internal/cron does that, // and computes the next run — just a sentence for the person reading the schedule, who // should not have to decode "15 3 * * 1" in their head. // // Anything it can't say plainly it declines to describe, and the caller shows the // expression instead. A confident wrong sentence is worse than five fields. const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] const DAY_ALIAS: Record = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 } const MONTH_ALIAS: Record = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 } // "@daily" and friends, which crontab(5) accepts in place of the five fields. const ALIASES: Record = { '@yearly': 'once a year, on 1 January at midnight', '@annually': 'once a year, on 1 January at midnight', '@monthly': 'monthly, on the 1st at midnight', '@weekly': 'weekly, on Sunday at midnight', '@daily': 'daily at midnight', '@midnight': 'daily at midnight', '@hourly': 'hourly, on the hour', '@reboot': 'at every boot', } const clock = (h: number, m: number) => { if (m === 0 && h === 0) return 'midnight' if (m === 0 && h === 12) return 'noon' const ampm = h < 12 ? 'am' : 'pm' const h12 = h % 12 === 0 ? 12 : h % 12 return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, '0')}${ampm}` } // Monday-to-Friday and Saturday-Sunday are worth naming; the rest get listed. function dayNames(d: number[]): string { const set = d.join(',') if (set === '1,2,3,4,5') return 'weekdays' if (set === '0,6') return 'weekends' return list(d.map((x) => DAYS[x])) } const list = (parts: string[]) => parts.length <= 1 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` const ordinal = (n: number) => { const s = ['th', 'st', 'nd', 'rd'][(n % 100 - 20) % 10] ?? ['th', 'st', 'nd', 'rd'][n % 100] ?? 'th' return `${n}${s}` } // values of one field, or null when it is a form this doesn't describe function values(field: string, min: number, max: number, alias: Record = {}): number[] | null { const out = new Set() for (const part of field.split(',')) { const [range, stepText] = part.split('/') const step = stepText === undefined ? 1 : Number(stepText) if (!Number.isInteger(step) || step < 1) return null let lo: number, hi: number if (range === '*') { lo = min; hi = max } else { const ends = range.split('-').map((v) => { const n = alias[v.toLowerCase()] ?? Number(v) return Number.isInteger(n) ? n : NaN }) if (ends.some(Number.isNaN) || ends.length > 2) return null lo = ends[0] hi = ends.length === 2 ? ends[1] : (stepText === undefined ? ends[0] : max) } if (lo < min || hi > max || lo > hi) return null for (let v = lo; v <= hi; v += step) out.add(v) } return [...out].sort((a, b) => a - b) } const everyValue = (v: number[], min: number, max: number) => v.length === max - min + 1 // stepOf reports the interval when a field is a plain "every n" over its whole range. function stepOf(v: number[], min: number, max: number): number | null { if (v.length < 2 || v[0] !== min) return null const step = v[1] - v[0] for (let i = 1; i < v.length; i++) if (v[i] - v[i - 1] !== step) return null return v[v.length - 1] + step > max ? step : null } /** describeCron turns "15 3 * * *" into "daily at 3:15am", or returns "" if it can't. */ export function describeCron(expr: string): string { const raw = (expr ?? '').trim() if (!raw) return '' if (raw.startsWith('@')) return ALIASES[raw.toLowerCase()] ?? '' const f = raw.split(/\s+/) if (f.length !== 5) return '' // 6-field (seconds) and Quartz forms aren't ours to guess at const [minF, hourF, domF, monF, dowF] = f const mins = values(minF, 0, 59) const hours = values(hourF, 0, 23) const doms = values(domF, 1, 31) const mons = values(monF, 1, 12, MONTH_ALIAS) const dows = values(dowF.replace(/7/g, '0'), 0, 6, DAY_ALIAS) if (!mins || !hours || !doms || !mons || !dows) return '' // --- when in the day --- let time: string const everyMin = everyValue(mins, 0, 59) const everyHour = everyValue(hours, 0, 23) const minStep = stepOf(mins, 0, 59) const hourStep = stepOf(hours, 0, 23) if (everyMin && everyHour) time = 'every minute' else if (everyMin) time = `every minute of ${list(hours.map((h) => clock(h, 0).replace(/(am|pm)/, '$1')))}` else if (minStep && everyHour) time = `every ${minStep} minutes` else if (minStep) time = `every ${minStep} minutes, ${hours.length === 1 ? `during the ${clock(hours[0], 0)} hour` : `during ${list(hours.map((h) => clock(h, 0)))}`}` else if (everyHour && mins.length === 1) time = mins[0] === 0 ? 'hourly, on the hour' : `hourly, at ${mins[0]} past` else if (hourStep && mins.length === 1) time = `every ${hourStep} hours` + (mins[0] === 0 ? ', on the hour' : `, at ${mins[0]} past`) else if (mins.length === 1 && hours.length >= 1) time = `at ${list(hours.map((h) => clock(h, mins[0])))}` else { const times: string[] = [] for (const h of hours) for (const m of mins) times.push(clock(h, m)) if (times.length > 6) return '' // a wall of times is not a translation time = `at ${list(times)}` } // --- which days --- const allDom = everyValue(doms, 1, 31) const allDow = everyValue(dows, 0, 6) const allMon = everyValue(mons, 1, 12) let days = '' if (allDom && allDow) days = '' else if (allDom) days = `on ${dayNames(dows)}` else if (allDow) days = `on the ${list(doms.map(ordinal))}` // crontab(5): with both set, the job runs when *either* matches — worth saying out loud else days = `on the ${list(doms.map(ordinal))}, and on ${dayNames(dows)}` const months = allMon ? '' : `in ${list(mons.map((m) => MONTHS[m - 1]))}` const daily = !days && !months const head = daily ? (time.startsWith('every') || time.startsWith('hourly') ? '' : 'daily ') : '' return [head + time, days, months].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() }