642 lines
21 KiB
JavaScript
642 lines
21 KiB
JavaScript
'use strict';
|
|
|
|
// ============================================================================
|
|
// cron.js — Cron expression parser, describer, validator, and next-run engine.
|
|
// Zero dependencies. Extracted from the DevRef Cron Expression Generator
|
|
// (battle-tested in browser) and extended with validate() for Pro insights.
|
|
// ============================================================================
|
|
|
|
const MONTH_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
|
const DAY_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
|
|
|
|
const FIELDS = [
|
|
{ name: 'minute', min: 0, max: 59, key: 'minute' },
|
|
{ name: 'hour', min: 0, max: 23, key: 'hour' },
|
|
{ name: 'dom', min: 1, max: 31, key: 'dom' },
|
|
{ name: 'month', min: 1, max: 12, key: 'month', named: MONTH_NAMES },
|
|
{ name: 'dow', min: 0, max: 7, key: 'dow', named: DAY_NAMES },
|
|
];
|
|
|
|
class CronError extends Error {
|
|
constructor(message, fieldIndex) {
|
|
super(message);
|
|
this.name = 'CronError';
|
|
this.fieldIndex = fieldIndex;
|
|
}
|
|
}
|
|
|
|
// ---- Name resolution ----
|
|
function resolveName(token, names) {
|
|
if (!names) return null;
|
|
const up = token.toUpperCase();
|
|
const idx = names.indexOf(up);
|
|
return idx === -1 ? null : idx;
|
|
}
|
|
|
|
// ---- Field parsing ----
|
|
function parseField(raw, fieldDef, fieldIndex) {
|
|
const trimmed = String(raw).trim();
|
|
if (trimmed === '') throw new CronError(`Field ${fieldIndex + 1} (${fieldDef.name}) is empty`, fieldIndex);
|
|
|
|
const out = { raw: trimmed, values: null, special: null };
|
|
|
|
// Special: day-of-week "#" (nth weekday)
|
|
if (fieldDef.key === 'dow' && trimmed.includes('#')) {
|
|
const m = trimmed.match(/^([0-7A-Za-z]+)#([1-5])$/);
|
|
if (!m) throw new CronError(`Invalid "#" syntax in day-of-week: "${trimmed}"`, fieldIndex);
|
|
let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
|
|
if (dowNum === 7) dowNum = 0;
|
|
out.special = { kind: 'hash', dow: dowNum, nth: parseInt(m[2], 10) };
|
|
return out;
|
|
}
|
|
|
|
// Special: day-of-week "L" (last weekday)
|
|
if (fieldDef.key === 'dow' && /L$/i.test(trimmed)) {
|
|
const m = trimmed.match(/^([0-7A-Za-z]+)L$/i);
|
|
if (!m) throw new CronError(`Invalid "L" syntax in day-of-week: "${trimmed}"`, fieldIndex);
|
|
let dowNum = parseSingleNum(m[1], fieldDef, fieldIndex);
|
|
if (dowNum === 7) dowNum = 0;
|
|
out.special = { kind: 'dowLast', dow: dowNum };
|
|
return out;
|
|
}
|
|
|
|
// Special: day-of-month "L" (last day)
|
|
if (fieldDef.key === 'dom' && /^L/i.test(trimmed)) {
|
|
const m = trimmed.match(/^L(?:-(\d+))?$/i);
|
|
if (!m) throw new CronError(`Invalid "L" syntax in day-of-month: "${trimmed}"`, fieldIndex);
|
|
out.special = { kind: 'domLast', offset: m[1] ? parseInt(m[1], 10) : 0 };
|
|
return out;
|
|
}
|
|
|
|
// Special: day-of-month "W" (nearest weekday)
|
|
if (fieldDef.key === 'dom' && /W$/i.test(trimmed)) {
|
|
const m = trimmed.match(/^(\d+)W$/i);
|
|
if (!m) throw new CronError(`Invalid "W" syntax in day-of-month: "${trimmed}"`, fieldIndex);
|
|
const day = parseInt(m[1], 10);
|
|
if (day < fieldDef.min || day > fieldDef.max) {
|
|
throw new CronError(`Day-of-month "${day}W" out of range (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
|
|
}
|
|
out.special = { kind: 'weekday', day: day };
|
|
return out;
|
|
}
|
|
|
|
// Standard parsing
|
|
const values = new Set();
|
|
const items = trimmed.split(',');
|
|
for (const item of items) {
|
|
parseItem(item, fieldDef, fieldIndex, values);
|
|
}
|
|
out.values = values;
|
|
return out;
|
|
}
|
|
|
|
function parseSingleNum(token, fieldDef, fieldIndex) {
|
|
const value = String(token).trim();
|
|
if (/^\d+$/.test(value)) return Number(value);
|
|
const named = resolveName(value, fieldDef.named);
|
|
if (named !== null) {
|
|
return fieldDef.key === 'month' ? named + 1 : named;
|
|
}
|
|
throw new CronError(`Invalid value "${value}" in ${fieldDef.name}`, fieldIndex);
|
|
}
|
|
|
|
function parseItem(item, fieldDef, fieldIndex, values) {
|
|
const t = item.trim();
|
|
if (t === '') throw new CronError(`Empty item in ${fieldDef.name}`, fieldIndex);
|
|
|
|
if (t === '*') {
|
|
addRange(values, fieldDef.min, fieldDef.max, fieldDef);
|
|
return;
|
|
}
|
|
|
|
if (t.includes('/')) {
|
|
const stepParts = t.split('/');
|
|
if (stepParts.length !== 2) throw new CronError(`Invalid step syntax "${t}" in ${fieldDef.name}`, fieldIndex);
|
|
const [base, stepStr] = stepParts;
|
|
if (!/^\d+$/.test(stepStr)) throw new CronError(`Invalid step "${stepStr}" in ${fieldDef.name}`, fieldIndex);
|
|
const step = Number(stepStr);
|
|
if (step < 1) throw new CronError(`Invalid step "${stepStr}" in ${fieldDef.name}`, fieldIndex);
|
|
let lo, hi;
|
|
if (base === '*' || base === '') {
|
|
lo = fieldDef.min; hi = fieldDef.max;
|
|
} else if (base.includes('-')) {
|
|
const [a, b] = base.split('-');
|
|
lo = parseSingleNum(a.trim(), fieldDef, fieldIndex);
|
|
hi = parseSingleNum(b.trim(), fieldDef, fieldIndex);
|
|
} else {
|
|
lo = parseSingleNum(base.trim(), fieldDef, fieldIndex);
|
|
hi = fieldDef.max;
|
|
}
|
|
if (lo > hi) [lo, hi] = [hi, lo];
|
|
for (let v = lo; v <= hi; v += step) addOne(values, v, fieldDef, fieldIndex);
|
|
return;
|
|
}
|
|
|
|
if (t.includes('-')) {
|
|
const parts = t.split('-');
|
|
if (parts.length !== 2) throw new CronError(`Invalid range "${t}" in ${fieldDef.name}`, fieldIndex);
|
|
const a = parseSingleNum(parts[0].trim(), fieldDef, fieldIndex);
|
|
const b = parseSingleNum(parts[1].trim(), fieldDef, fieldIndex);
|
|
addRange(values, a, b, fieldDef);
|
|
return;
|
|
}
|
|
|
|
const v = parseSingleNum(t, fieldDef, fieldIndex);
|
|
addOne(values, v, fieldDef, fieldIndex);
|
|
}
|
|
|
|
function addOne(values, v, fieldDef, fieldIndex) {
|
|
if (fieldDef.key === 'dow' && v === 7) { values.add(0); return; }
|
|
if (v < fieldDef.min || v > fieldDef.max) {
|
|
throw new CronError(`Value ${v} out of range for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, fieldIndex);
|
|
}
|
|
values.add(v);
|
|
}
|
|
|
|
function addRange(values, lo, hi, fieldDef) {
|
|
if (lo > hi) [lo, hi] = [hi, lo];
|
|
if (lo < fieldDef.min || hi > fieldDef.max) {
|
|
throw new CronError(`Range ${lo}-${hi} out of bounds for ${fieldDef.name} (${fieldDef.min}-${fieldDef.max})`, -1);
|
|
}
|
|
for (let v = lo; v <= hi; v++) {
|
|
if (fieldDef.key === 'dow' && v === 7) { values.add(0); continue; }
|
|
values.add(v);
|
|
}
|
|
}
|
|
|
|
// ---- Full expression parser ----
|
|
function parseCron(expr) {
|
|
const parts = String(expr).trim().split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
throw new CronError(`Expected 5 fields (got ${parts.length}). Format: minute hour day-of-month month day-of-week`, -1);
|
|
}
|
|
const parsed = {};
|
|
for (let i = 0; i < 5; i++) {
|
|
parsed[FIELDS[i].key] = parseField(parts[i], FIELDS[i], i);
|
|
}
|
|
parsed.domRestricted = !/^\s*\*\s*$/.test(parts[2]);
|
|
parsed.dowRestricted = !/^\s*\*\s*$/.test(parts[4]);
|
|
parsed.parts = parts;
|
|
return parsed;
|
|
}
|
|
|
|
// ---- Human-readable description ----
|
|
function describe(expr) {
|
|
let parsed;
|
|
try { parsed = parseCron(expr); } catch (e) { return { text: e.message, error: true }; }
|
|
return { text: describeParsed(parsed), error: false, parsed };
|
|
}
|
|
|
|
function describeParsed(p) {
|
|
const monthDesc = describeFieldMonth(p.month);
|
|
const domDesc = describeFieldDom(p.dom);
|
|
const dowDesc = describeFieldDow(p.dow);
|
|
|
|
const isEveryMin = p.parts[0] === '*';
|
|
const isEveryHour = p.parts[1] === '*';
|
|
|
|
let timePart = '';
|
|
if (isEveryMin && isEveryHour) {
|
|
timePart = 'At every minute';
|
|
} else if (isEveryMin && !isEveryHour) {
|
|
const hours = [...(p.hour.values || [])].sort((a, b) => a - b);
|
|
if (hours.length > 0) {
|
|
timePart = 'Every minute during the ' + hours.map(h => pad2(h)).join(', ') + ' hour' + (hours.length > 1 ? 's' : '');
|
|
} else {
|
|
timePart = 'Every minute';
|
|
}
|
|
} else {
|
|
timePart = 'At ' + describeTimes(p.minute, p.hour);
|
|
}
|
|
|
|
let dayPart = '';
|
|
const domAny = !p.domRestricted;
|
|
const dowAny = !p.dowRestricted;
|
|
|
|
if (domAny && dowAny) {
|
|
if (monthDesc.restricted) {
|
|
dayPart = ', ' + monthDesc.text + ' of every year';
|
|
} else {
|
|
dayPart = ', every day';
|
|
}
|
|
} else if (!domAny && dowAny) {
|
|
dayPart = ', on ' + domDesc.text;
|
|
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
|
|
} else if (domAny && !dowAny) {
|
|
dayPart = ', on ' + dowDesc.text;
|
|
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
|
|
} else {
|
|
dayPart = ', on ' + domDesc.text + ' and on ' + dowDesc.text;
|
|
if (monthDesc.restricted) dayPart += ' in ' + monthDesc.text;
|
|
}
|
|
|
|
return capitalize(timePart + dayPart);
|
|
}
|
|
|
|
function describeTimes(minuteField, hourField) {
|
|
const mins = [...(minuteField.values || [])].sort((a, b) => a - b);
|
|
const hours = [...(hourField.values || [])].sort((a, b) => a - b);
|
|
|
|
if (pIsWildcard(hourField) && !pIsWildcard(minuteField)) {
|
|
if (mins.length === 1) return `minute ${mins[0]} of every hour`;
|
|
return `minutes ${listJoin(mins)} of every hour`;
|
|
}
|
|
if (pIsWildcard(minuteField) && pIsWildcard(hourField)) return 'every minute of every hour';
|
|
|
|
if (pIsWildcard(minuteField)) {
|
|
return `every minute during the ${hours.map(h => pad2(h)).join(', ')} hour${hours.length > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
const combos = [];
|
|
for (const h of hours) {
|
|
for (const m of mins) {
|
|
combos.push(formatHM(h, m));
|
|
}
|
|
}
|
|
return listJoin(combos);
|
|
}
|
|
|
|
function describeFieldMonth(field) {
|
|
if (pIsWildcard(field)) return { restricted: false, text: 'every month' };
|
|
const vals = [...(field.values || [])].sort((a, b) => a - b);
|
|
return { restricted: true, text: 'in ' + listJoin(vals.map(v => capitalize(MONTH_NAMES[v - 1]))) };
|
|
}
|
|
|
|
function describeFieldDom(field) {
|
|
if (pIsWildcard(field)) return { text: 'every day-of-month' };
|
|
if (field.special) {
|
|
if (field.special.kind === 'domLast') {
|
|
return { text: field.special.offset === 0 ? 'the last day of the month' : `the last day of the month minus ${field.special.offset} days` };
|
|
}
|
|
if (field.special.kind === 'weekday') {
|
|
return { text: `the nearest weekday to day ${field.special.day}` };
|
|
}
|
|
}
|
|
const vals = [...(field.values || [])].sort((a, b) => a - b);
|
|
return { text: `day-of-month ${listJoin(vals)}` };
|
|
}
|
|
|
|
function describeFieldDow(field) {
|
|
if (pIsWildcard(field)) return { text: 'every day-of-week' };
|
|
if (field.special) {
|
|
if (field.special.kind === 'hash') {
|
|
return { text: `the ${ordinal(field.special.nth)} ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
|
|
}
|
|
if (field.special.kind === 'dowLast') {
|
|
return { text: `the last ${capitalize(DAY_NAMES[field.special.dow])} of the month` };
|
|
}
|
|
}
|
|
const vals = [...(field.values || [])].sort((a, b) => a - b);
|
|
return { text: listJoin(vals.map(v => capitalize(DAY_NAMES[v]))) };
|
|
}
|
|
|
|
function pIsWildcard(field) { return field.raw === '*'; }
|
|
|
|
// ---- Next run calculator ----
|
|
function nextRuns(expr, fromDate, count) {
|
|
count = count || 10;
|
|
const p = parseCron(expr);
|
|
const runs = [];
|
|
let d = new Date(fromDate.getTime());
|
|
d.setSeconds(0, 0);
|
|
d = new Date(d.getTime() + 60000);
|
|
|
|
let maxScan = 600000; // ~416 days ceiling
|
|
while (runs.length < count && maxScan-- > 0) {
|
|
if (matches(d, p)) {
|
|
runs.push(new Date(d.getTime()));
|
|
}
|
|
d = new Date(d.getTime() + 60000);
|
|
}
|
|
return runs;
|
|
}
|
|
|
|
function matches(d, p) {
|
|
if (!p.minute.values || !p.minute.values.has(d.getMinutes())) return false;
|
|
if (!p.hour.values || !p.hour.values.has(d.getHours())) return false;
|
|
if (!p.month.values || !p.month.values.has(d.getMonth() + 1)) return false;
|
|
|
|
const domAny = !p.domRestricted;
|
|
const dowAny = !p.dowRestricted;
|
|
|
|
let domMatch = false, dowMatch = false;
|
|
if (domAny) {
|
|
domMatch = true;
|
|
} else if (p.dom.special) {
|
|
domMatch = matchDomSpecial(d, p.dom.special);
|
|
} else if (p.dom.values && p.dom.values.has(d.getDate())) {
|
|
domMatch = true;
|
|
}
|
|
if (dowAny) {
|
|
dowMatch = true;
|
|
} else if (p.dow.special) {
|
|
dowMatch = matchDowSpecial(d, p.dow.special);
|
|
} else if (p.dow.values) {
|
|
dowMatch = p.dow.values.has(d.getDay());
|
|
}
|
|
|
|
if (domAny && dowAny) return true;
|
|
if (!domAny && !dowAny) return domMatch || dowMatch; // OR semantics
|
|
return domMatch && dowMatch;
|
|
}
|
|
|
|
function matchDomSpecial(d, special) {
|
|
if (special.kind === 'domLast') {
|
|
const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
|
|
const target = special.offset === 0 ? lastDay : lastDay - special.offset;
|
|
return d.getDate() === target;
|
|
}
|
|
if (special.kind === 'weekday') {
|
|
return d.getDate() === nearestWeekday(d.getFullYear(), d.getMonth(), special.day);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function matchDowSpecial(d, special) {
|
|
if (special.kind === 'hash') {
|
|
return nthWeekdayMatches(d, special.dow, special.nth);
|
|
}
|
|
if (special.kind === 'dowLast') {
|
|
return lastWeekdayMatches(d, special.dow);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function nthWeekdayMatches(d, dow, nth) {
|
|
if (d.getDay() !== dow) return false;
|
|
const dayOfMonth = d.getDate();
|
|
const occurrence = Math.ceil(dayOfMonth / 7);
|
|
return occurrence === nth;
|
|
}
|
|
|
|
function lastWeekdayMatches(d, dow) {
|
|
if (d.getDay() !== dow) return false;
|
|
const lastDay = lastDayOfMonth(d.getFullYear(), d.getMonth());
|
|
return d.getDate() + 7 > lastDay;
|
|
}
|
|
|
|
function lastDayOfMonth(year, month) {
|
|
return new Date(year, month + 1, 0).getDate();
|
|
}
|
|
|
|
function nearestWeekday(year, month, day) {
|
|
const lastDay = lastDayOfMonth(year, month);
|
|
const target = Math.min(day, lastDay);
|
|
const dt = new Date(year, month, target);
|
|
const wd = dt.getDay();
|
|
let result = target;
|
|
if (wd === 0) {
|
|
if (target + 1 <= lastDay) result = target + 1;
|
|
else result = target - 2;
|
|
} else if (wd === 6) {
|
|
if (target - 1 >= 1) result = target - 1;
|
|
else result = target + 2;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ============================================================================
|
|
// validate() — Pro-tier feature: deeper analysis of a cron expression.
|
|
// Returns warnings, observations, and optimization suggestions.
|
|
// ============================================================================
|
|
|
|
function validate(expr) {
|
|
let parsed;
|
|
try {
|
|
parsed = parseCron(expr);
|
|
} catch (e) {
|
|
return {
|
|
valid: false,
|
|
error: e.message,
|
|
fieldIndex: e.fieldIndex,
|
|
warnings: [],
|
|
observations: [],
|
|
suggestions: [],
|
|
};
|
|
}
|
|
|
|
const warnings = [];
|
|
const observations = [];
|
|
const suggestions = [];
|
|
|
|
const desc = describeParsed(parsed);
|
|
|
|
// Check: day-of-month and day-of-week both restricted (OR semantics surprise)
|
|
if (parsed.domRestricted && parsed.dowRestricted) {
|
|
warnings.push({
|
|
level: 'high',
|
|
message: 'Both day-of-month and day-of-week are restricted. Cron uses OR semantics for these fields — the job will run when EITHER matches, not both. This is a common source of bugs.',
|
|
});
|
|
}
|
|
|
|
// Check: impossible day-of-month values (e.g., 31 in Feb)
|
|
const domValues = [...(parsed.dom.values || [])];
|
|
if (!parsed.domRestricted && parsed.month.values && ![...parsed.month.values].every(m => m === 2)) {
|
|
// skip
|
|
} else if (parsed.domRestricted && !parsed.dom.special && domValues.includes(31)) {
|
|
const monthsWith31 = [1, 3, 5, 7, 8, 10, 12]; // Jan, Mar, May, Jul, Aug, Oct, Dec
|
|
const monthValues = parsed.month.values ? [...parsed.month.values] : [];
|
|
const restrictedMonths = parsed.parts[3] !== '*';
|
|
if (restrictedMonths) {
|
|
const problemMonths = monthValues.filter(m => !monthsWith31.includes(m));
|
|
if (problemMonths.length > 0) {
|
|
warnings.push({
|
|
level: 'medium',
|
|
message: `Day 31 is specified but months ${problemMonths.map(m => capitalize(MONTH_NAMES[m - 1])).join(', ')} have fewer than 31 days. The job will never run in those months.`,
|
|
});
|
|
}
|
|
} else {
|
|
observations.push({
|
|
level: 'info',
|
|
message: 'Day 31 will only match in months with 31 days (7 of 12 months). The job effectively skips Feb, Apr, Jun, Sep, and Nov.',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check: high-frequency schedules
|
|
if (parsed.parts[0] === '*' && parsed.parts[1] === '*') {
|
|
observations.push({
|
|
level: 'info',
|
|
message: 'This expression runs every minute. For production jobs, consider if this frequency is intentional.',
|
|
});
|
|
}
|
|
|
|
// Check: step values that don't divide evenly
|
|
for (let i = 0; i < 2; i++) {
|
|
const part = parsed.parts[i];
|
|
if (part.startsWith('*/')) {
|
|
const step = parseInt(part.slice(2), 10);
|
|
const range = i === 0 ? 60 : 24;
|
|
if (range % step !== 0) {
|
|
observations.push({
|
|
level: 'info',
|
|
message: `Step value */${step} in ${FIELDS[i].name} doesn't divide evenly into ${range}. The last interval will be shorter than the rest (e.g., */7 in minutes goes 0,7,14,...,56, then 0 again — not 63).`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check: February 29th edge case
|
|
if (parsed.domRestricted && !parsed.dom.special) {
|
|
const domVals = [...(parsed.dom.values || [])];
|
|
const monthVals = parsed.month.values ? [...parsed.month.values] : [];
|
|
if (domVals.includes(29) && monthVals.length === 1 && monthVals[0] === 2) {
|
|
warnings.push({
|
|
level: 'medium',
|
|
message: 'February 29th only occurs in leap years. This job will not run at all in non-leap years (3 out of every 4 years).',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check: midnight rush
|
|
if (parsed.parts[0] === '0' && parsed.parts[1] === '0') {
|
|
suggestions.push({
|
|
level: 'info',
|
|
message: 'Midnight (00:00) is a common schedule and many systems have concurrent job spikes at this time. Consider offsetting to a few minutes past midnight (e.g., 02 0 * * *) to avoid resource contention.',
|
|
});
|
|
}
|
|
|
|
// Check: weekend vs weekday
|
|
if (parsed.parts[4] === '1-5') {
|
|
observations.push({
|
|
level: 'info',
|
|
message: 'Weekdays only (Mon-Fri). This job will not run on weekends.',
|
|
});
|
|
}
|
|
|
|
// Compute frequency estimate
|
|
const freq = estimateFrequency(parsed);
|
|
if (freq) {
|
|
observations.push({
|
|
level: 'info',
|
|
message: `Approximate frequency: ${freq.description} (~${freq.runsPerYear} runs per year).`,
|
|
});
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
description: desc,
|
|
warnings,
|
|
observations,
|
|
suggestions,
|
|
parsed,
|
|
};
|
|
}
|
|
|
|
function estimateFrequency(parsed) {
|
|
try {
|
|
// Count runs over a sample year
|
|
const start = new Date(2025, 0, 1, 0, 0, 0, 0);
|
|
const end = new Date(2026, 0, 1, 0, 0, 0, 0);
|
|
let count = 0;
|
|
let d = new Date(start.getTime());
|
|
let maxScan = 540000; // ~375 days
|
|
while (d < end && maxScan-- > 0) {
|
|
if (matches(d, parsed)) count++;
|
|
d = new Date(d.getTime() + 60000);
|
|
}
|
|
|
|
let description = '';
|
|
if (count >= 525600) description = 'every minute';
|
|
else if (count >= 500000) description = 'multiple times per minute';
|
|
else if (count >= 8000) description = 'hourly or more';
|
|
else if (count >= 300) description = 'daily or more';
|
|
else if (count >= 40) description = 'weekly or more';
|
|
else if (count >= 8) description = 'monthly or more';
|
|
else if (count >= 1) description = 'yearly or less';
|
|
else description = 'never (impossible schedule)';
|
|
|
|
return { description, runsPerYear: count };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---- Presets ----
|
|
const PRESETS = [
|
|
{ label: 'Every minute', cron: '* * * * *' },
|
|
{ label: 'Every 5 min', cron: '*/5 * * * *' },
|
|
{ label: 'Every 10 min', cron: '*/10 * * * *' },
|
|
{ label: 'Every 15 min', cron: '*/15 * * * *' },
|
|
{ label: 'Every 30 min', cron: '*/30 * * * *' },
|
|
{ label: 'Hourly', cron: '0 * * * *' },
|
|
{ label: 'Every 2 hours', cron: '0 */2 * * *' },
|
|
{ label: 'Every 6 hours', cron: '0 */6 * * *' },
|
|
{ label: 'Every 12 hours', cron: '0 */12 * * *' },
|
|
{ label: 'Daily at midnight', cron: '0 0 * * *' },
|
|
{ label: 'Daily 9am', cron: '0 9 * * *' },
|
|
{ label: 'Twice daily', cron: '0 9,21 * * *' },
|
|
{ label: 'Weekdays 9am', cron: '0 9 * * 1-5' },
|
|
{ label: 'Weekends 10am', cron: '0 10 * * 0,6' },
|
|
{ label: 'Every Monday', cron: '0 0 * * 1' },
|
|
{ label: 'Every Friday', cron: '0 0 * * 5' },
|
|
{ label: 'Monthly 1st', cron: '0 0 1 * *' },
|
|
{ label: 'Quarterly', cron: '0 0 1 */3 *' },
|
|
{ label: 'Yearly Jan 1', cron: '0 0 1 1 *' },
|
|
];
|
|
|
|
const COMMON = [
|
|
{ label: 'At 14:30', cron: '30 14 * * *' },
|
|
{ label: '9am weekdays', cron: '0 9 * * 1-5' },
|
|
{ label: 'Every Mon 8am', cron: '0 8 * * 1' },
|
|
{ label: 'Last day of month', cron: '0 0 L * *' },
|
|
{ label: '15th, weekday', cron: '0 0 15W * *' },
|
|
{ label: '3rd Thursday', cron: '0 0 * * 4#3' },
|
|
{ label: 'Last Friday', cron: '0 0 * * 5L' },
|
|
{ label: 'Business hours', cron: '0 9-17 * * 1-5' },
|
|
{ label: 'Backup nightly', cron: '0 2 * * *' },
|
|
];
|
|
|
|
// ---- Helpers ----
|
|
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
function formatHM(h, m) { return `${pad2(h)}:${pad2(m)}`; }
|
|
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
function ordinal(n) {
|
|
const s = ['th', 'st', 'nd', 'rd'];
|
|
const v = n % 100;
|
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
}
|
|
function listJoin(arr) {
|
|
if (arr.length === 0) return '';
|
|
if (arr.length === 1) return String(arr[0]);
|
|
if (arr.length === 2) return `${arr[0]} and ${arr[1]}`;
|
|
return arr.slice(0, -1).join(', ') + ', and ' + arr[arr.length - 1];
|
|
}
|
|
|
|
function formatNextRuns(runs, fromDate) {
|
|
return runs.map(r => {
|
|
const diff = r.getTime() - fromDate.getTime();
|
|
const mins = Math.round(diff / 60000);
|
|
let rel;
|
|
if (mins < 60) rel = `+${mins}m`;
|
|
else if (mins < 2880) rel = `+${Math.round(mins / 60)}h`;
|
|
else rel = `+${Math.round(mins / 1440)}d`;
|
|
return { date: r, relative: rel, formatted: r.toISOString() };
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
CronError,
|
|
FIELDS,
|
|
MONTH_NAMES,
|
|
DAY_NAMES,
|
|
PRESETS,
|
|
COMMON,
|
|
parseCron,
|
|
describe,
|
|
describeParsed,
|
|
nextRuns,
|
|
matches,
|
|
validate,
|
|
estimateFrequency,
|
|
formatNextRuns,
|
|
parseField,
|
|
parseItem,
|
|
parseSingleNum,
|
|
resolveName,
|
|
lastDayOfMonth,
|
|
nearestWeekday,
|
|
nthWeekdayMatches,
|
|
lastWeekdayMatches,
|
|
};
|