diff --git a/README.md b/README.md index 98c2dde..6faa514 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,25 @@ Clauses for different fields compose: | `every january` | `0 0 0 1 1 *` | | `last friday of the month` | `0 0 0 * * 5L` | +## Reverse: describe a cron expression + +`toHuman` goes the other way, turning a cron expression (5 or 6 fields) back into +plain English. It's the readback for a `toCron` call: "you wrote X, here is the +cron, which reads as Y." + +```js +import { toHuman } from 'cron-translate'; + +toHuman('0 0 9 * * *'); // "at 9am" +toHuman('0 0 18 * * 1-5'); // "at 6pm on monday to friday" +toHuman('0 */5 9 * * *'); // "every 5 minutes at 9am" +toHuman('0 0 0 * * 5L'); // "last friday of the month" +``` + +The output is phrased so that `toCron` parses it back to the same expression: +`toCron(toHuman(cron))` returns the original cron. The English may read differently +from the phrase you started with, but it describes the same schedule. + ## Errors `toCron` throws a `CronTranslateError` when the phrase can't be turned into a valid diff --git a/src/index.ts b/src/index.ts index 57d0cde..3e8116f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { toCron, CronTranslateError } from './to-cron'; +import { toHuman } from './to-human'; -export { toCron, CronTranslateError }; +export { toCron, toHuman, CronTranslateError }; export default toCron; diff --git a/src/to-human.ts b/src/to-human.ts new file mode 100644 index 0000000..855efc3 --- /dev/null +++ b/src/to-human.ts @@ -0,0 +1,172 @@ +// cron-translate — reverse direction: cron -> Schedule -> English (readback). +// Produces a readable sentence that toCron() parses back to the same expression, +// so `toCron(toHuman(cron))` round-trips. See PRODUCT.md §9. + +import { CronTranslateError } from './to-cron'; + +const MONTH_NAMES = [ + '', 'january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december', +]; +const DOW_NAMES = [ + 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', +]; +const ORD_WORDS = ['', 'first', 'second', 'third', 'fourth', 'fifth']; + +const MONTH_NUMS: 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, +}; +const DOW_NUMS: Record = { + sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, +}; + +const wild = (v: string): boolean => v === '*'; +const stepOf = (v: string): number | null => { + const m = /^\*\/(\d+)$/.exec(v); + return m ? Number(m[1]) : null; +}; +const isSpecific = (v: string): boolean => !wild(v) && stepOf(v) === null; +const isSingle = (v: string): boolean => /^\d+$/.test(v); +const isRange = (v: string): boolean => /^\d+-\d+$/.test(v); + +// Map full/abbreviated names in a field to numbers (e.g. "MON-FRI" -> "1-5"). +function mapNames(field: string, table: Record): string { + return field.replace(/[a-z]+/gi, (word) => { + const key = word.toLowerCase().slice(0, 3); + return key in table ? String(table[key]) : word; + }); +} + +function clock(h: number, m: number): string { + 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}`; +} + +// Render a field value (single / list / range) into English, optionally via names. +function renderValues(field: string, names?: string[]): string { + const name = (v: string): string => (names ? names[Number(v)] : v); + const parts = field.split(',').map((part) => { + const dash = part.indexOf('-'); + if (dash > 0) return `${name(part.slice(0, dash))} to ${name(part.slice(dash + 1))}`; + return name(part); + }); + if (parts.length === 1) return parts[0]; + return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`; +} + +// A specific (non-wild, non-step) hour value rendered as a time clause. +function hourClause(hour: string): string { + if (isSingle(hour)) return `at ${clock(Number(hour), 0)}`; + if (isRange(hour)) { + const dash = hour.indexOf('-'); + return `between ${clock(Number(hour.slice(0, dash)), 0)} and ${clock(Number(hour.slice(dash + 1)), 0)}`; + } + return `at hour ${renderValues(hour)}`; +} + +function renderTime(sec: string, min: string, hour: string): string[] { + const cl: string[] = []; + const second = (): void => { if (sec !== '0' && isSpecific(sec)) cl.push(`at second ${renderValues(sec)}`); }; + const coarserHour = (): void => { if (isSpecific(hour)) cl.push(hourClause(hour)); }; + + const secStep = stepOf(sec); + const minStep = stepOf(min); + const hourStep = stepOf(hour); + + // Base frequency = the finest wild / step field. + if (wild(sec)) { cl.push('every second'); coarserHour(); return cl; } + if (secStep !== null) { cl.push(`every ${secStep} seconds`); coarserHour(); return cl; } + + if (wild(min)) { cl.push('every minute'); coarserHour(); second(); return cl; } + if (minStep !== null) { cl.push(`every ${minStep} minutes`); coarserHour(); second(); return cl; } + + if (wild(hour)) { + if (min === '0') { + if (sec === '0') { cl.push('every hour'); return cl; } + cl.push(`at second ${renderValues(sec)}`); + cl.push('at minute 0'); + return cl; + } + cl.push(`at minute ${renderValues(min)}`); + second(); + return cl; + } + if (hourStep !== null) { + cl.push(`every ${hourStep} hours`); + if (min !== '0') cl.push(`at minute ${renderValues(min)}`); + second(); + return cl; + } + + // Hour is specific: a clock time. + if (sec === '0' && min === '0') { cl.push(hourClause(hour)); return cl; } + if (sec === '0' && isSingle(hour) && isSingle(min)) { + cl.push(`at ${clock(Number(hour), Number(min))}`); + return cl; + } + cl.push(`at hour ${renderValues(hour)}`); + if (min !== '0') cl.push(`at minute ${renderValues(min)}`); + second(); + return cl; +} + +function renderConstraints(dom: string, month: string, dow: string): string[] { + const cl: string[] = []; + + if (dom !== '*') { + const step = stepOf(dom); + if (step !== null) cl.push(`every ${step} days`); + else if (dom === 'L') cl.push('last day of the month'); + else cl.push(`on day ${renderValues(dom)}`); + } + + if (month !== '*') { + const step = stepOf(month); + if (step !== null) cl.push(`every ${step} months`); + else cl.push(`in ${renderValues(month, MONTH_NAMES)}`); + } + + if (dow !== '*') { + const last = /^(\d+)L$/.exec(dow); + const nth = /^(\d+)#(\d+)$/.exec(dow); + if (last) cl.push(`last ${DOW_NAMES[Number(last[1])]} of the month`); + else if (nth) cl.push(`${ORD_WORDS[Number(nth[2])]} ${DOW_NAMES[Number(nth[1])]} of the month`); + else cl.push(`on ${renderValues(dow, DOW_NAMES)}`); + } + + return cl; +} + +/** + * Describe a node-cron expression (5 or 6 fields) in plain English. + * + * The output is phrased so that `toCron()` parses it back to the same + * expression — `toCron(toHuman(cron))` round-trips. + * + * @throws {CronTranslateError} when the expression doesn't have 5 or 6 fields. + */ +export function toHuman(expression: string): string { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5 && parts.length !== 6) { + throw new CronTranslateError(`Expected a 5- or 6-field cron expression, got ${parts.length} fields.`); + } + const [sec, min, hour, dom, monthRaw, dowRaw] = parts.length === 6 + ? parts + : ['0', ...parts]; + const month = mapNames(monthRaw, MONTH_NUMS); + const dow = mapNames(dowRaw, DOW_NUMS); + + const time = renderTime(sec, min, hour); + const constraints = renderConstraints(dom, month, dow); + + // "at midnight" (the all-zero day-level time) is the default cron defaults to, + // so drop it when a coarser constraint already carries the schedule. + const midnightDefault = sec === '0' && min === '0' && hour === '0'; + const clauses = midnightDefault && constraints.length ? constraints : [...time, ...constraints]; + + return clauses.join(' '); +} diff --git a/test/to-human.test.ts b/test/to-human.test.ts new file mode 100644 index 0000000..3c1e3d2 --- /dev/null +++ b/test/to-human.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { toCron, toHuman } from '../src/index'; + +// Reverse direction (readback). The core property: the English toHuman produces +// parses back to the same cron — toCron(toHuman(cron)) === cron. + +describe('toHuman — round-trips through toCron', () => { + const crons = [ + '0 * * * * *', '0 */5 * * * *', '0 */15 * * * *', '0 0 * * * *', '0 0 */2 * * *', + '0 0 0 * * *', '0 0 0 * * 0', '0 0 0 1 * *', '0 0 0 1 1 *', '0 0 0 */2 * *', '0 0 0 1 */2 *', + '0 0 9 * * *', '0 0 17 * * *', '0 30 14 * * *', '0 0 12 * * *', '0 */5 9 * * *', '0 */5 12 * * *', + '0 0 18,20 * * *', '30 * * * * *', '0,30 * * * * *', '0-10 * * * * *', '0 15 * * * *', + '0 0,15,30,45 * * * *', '0 1-30 * * * *', '0 0 9,12,17 * * *', '0 0 9-17 * * *', + '0 0 0 15 * *', '0 0 0 1,15 * *', '0 0 0 1-10 * *', '0 0 0 1 3 *', '0 0 0 1 3,6 *', '0 0 0 1 3-6 *', + '0 0 0 1 1,2,3 *', '0 0 0 * * 1', '0 0 0 * * 1,5', '0 0 0 * * 1-5', '0 0 18 * * 1-5', + '0 0 0 * * 0,6', '0 0 17 * * 2,4', '0 0 0 L * *', '0 0 0 * * 5L', '0 0 9 * * 1#1', + '0 0 6 * * *', '0 0 20 * * *', '0 */30 9-17 * * 1-5', '* * * * * *', '30 0 * * * *', + ]; + it.each(crons)('%s round-trips', (cron) => { + expect(toCron(toHuman(cron))).toBe(cron); + }); +}); + +describe('toHuman — 5-field input (seconds implied)', () => { + const cases: [string, string][] = [ + ['* * * * *', '0 * * * * *'], + ['0 9 * * 1-5', '0 0 9 * * 1-5'], + ['*/5 * * * *', '0 */5 * * * *'], + ]; + it.each(cases)('%s round-trips to %s', (five, six) => { + expect(toCron(toHuman(five))).toBe(six); + }); +}); + +describe('toHuman — readable phrasing', () => { + const cases: [string, string][] = [ + ['0 * * * * *', 'every minute'], + ['0 */5 * * * *', 'every 5 minutes'], + ['0 0 * * * *', 'every hour'], + ['0 0 0 * * *', 'at midnight'], + ['0 0 9 * * *', 'at 9am'], + ['0 30 14 * * *', 'at 2:30pm'], + ['0 0 12 * * *', 'at noon'], + ['0 0 18 * * 1-5', 'at 6pm on monday to friday'], + ['0 */5 9 * * *', 'every 5 minutes at 9am'], + ['0 0 0 * * 5L', 'last friday of the month'], + ['0 0 0 L * *', 'last day of the month'], + ['0 0,15,30,45 * * * *', 'at minute 0, 15, 30 and 45'], + ['0 1-30 * * * *', 'at minute 1 to 30'], + ['0 0 9-17 * * *', 'between 9am and 5pm'], + ['0 0 0 1,15 * *', 'on day 1 and 15'], + ['0 0 0 1 3-6 *', 'on day 1 in march to june'], + ['0 0 0 * * 0,6', 'on sunday and saturday'], + ['0 */30 9-17 * * 1-5', 'every 30 minutes between 9am and 5pm on monday to friday'], + ['30 * * * * *', 'every minute at second 30'], + ['0 0 0 1 * *', 'on day 1'], + ]; + it.each(cases)('%s => %s', (cron, expected) => { + expect(toHuman(cron)).toBe(expected); + }); +}); + +describe('toHuman — accepts month/weekday names in input', () => { + it.each([ + ['30 9 * * MON-FRI', '0 30 9 * * 1-5'], + ['0 0 0 1 JAN *', '0 0 0 1 1 *'], + ] as [string, string][])('%s round-trips to %s', (cron, six) => { + expect(toCron(toHuman(cron))).toBe(six); + }); +}); + +describe('toHuman — rejects malformed input', () => { + it.each(['', 'a b c', '1 2 3 4 5 6 7'])('rejects %j', (bad) => { + expect(() => toHuman(bad)).toThrow(); + }); +});