Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
172 changes: 172 additions & 0 deletions src/to-human.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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<string, number> = {
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, number>): 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(' ');
}
76 changes: 76 additions & 0 deletions test/to-human.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading