// A small five-field cron parser, for the board's scheduled jobs. // // Written here rather than pulled in as a dependency: the app ships with four // runtime dependencies and this is the whole of what the board needs — parse an // expression, say whether it is valid, and answer "when does this next fire?". // Pure and side-effect free, like the rest of lib/, so it is testable on its own. // // Times are the SERVER's local time. A schedule is written by the person // sitting in front of the machine the agents run on, so local is the reading // they mean; there is no per-user timezone anywhere else in the app either. const FIELDS = [ { key: 'minute', label: 'minute', min: 0, max: 59 }, { key: 'hour', label: 'hour', min: 0, max: 23 }, { key: 'dom', label: 'day of month', min: 1, max: 31 }, { key: 'month', label: 'month', min: 1, max: 12 }, // 7 is Sunday as well as 0, which is what every crontab accepts. { key: 'dow', label: 'day of week', min: 0, max: 7 }, ]; // The shorthands people actually type. Expanded before parsing so everything // downstream only ever deals with the five-field form. export const CRON_MACROS = { '@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 * * * *', }; // Bounds the stored text, for the same reason MAX_TITLE_LEN exists: this string // is persisted in config.json and rendered on a card. Mirrored by the // maxlength on #job-schedule in public/index.html — keep the two in step. export const MAX_SCHEDULE_LEN = 120; function parseField(text, spec) { const values = new Set(); for (const part of String(text).split(',')) { const piece = part.trim(); if (!piece) return { error: `Empty ${spec.label} in the schedule` }; // `*/15` and `1-30/5` share a step suffix; `*` and `a-b` are the same rule // with different bounds, so normalise both into a from/to pair. const [rangeText, stepText, ...extra] = piece.split('/'); if (extra.length > 0) return { error: `"${piece}" is not a valid ${spec.label}` }; let step = 1; if (stepText !== undefined) { if (!/^\d+$/.test(stepText.trim())) return { error: `"${piece}" has an invalid step for the ${spec.label}` }; step = parseInt(stepText.trim(), 10); if (step < 1) return { error: `The step in "${piece}" must be 1 or more` }; } let from, to; const range = rangeText.trim(); if (range === '*') { from = spec.min; to = spec.max; } else if (/^\d+$/.test(range)) { from = parseInt(range, 10); // A bare number with a step means "from here to the end of the field", // which is how crontab reads `5/10`. to = stepText === undefined ? from : spec.max; } else { const m = /^(\d+)-(\d+)$/.exec(range); if (!m) return { error: `"${piece}" is not a valid ${spec.label}` }; from = parseInt(m[1], 10); to = parseInt(m[2], 10); if (from > to) return { error: `The range "${range}" runs backwards for the ${spec.label}` }; } if (from < spec.min || to > spec.max) { return { error: `The ${spec.label} must be between ${spec.min} and ${spec.max} (got "${piece}")` }; } for (let v = from; v <= to; v += step) values.add(v === 7 && spec.key === 'dow' ? 0 : v); } return { values }; } /** * Parse a cron expression. * * @returns { minute, hour, dom, month, dow, domStar, dowStar, expr } — sets of * the matching values — or { error } with a message written for the * person who typed it. */ export function parseCron(expr) { const raw = String(expr || '').trim(); if (!raw) return { error: 'A scheduled job needs a cron schedule (for example "0 9 * * 1-5")' }; if (raw.length > MAX_SCHEDULE_LEN) return { error: 'That schedule is too long to be a cron expression' }; const expanded = raw.startsWith('@') ? CRON_MACROS[raw.toLowerCase()] : raw; if (!expanded) return { error: `Unknown schedule shorthand "${raw}" — try ${Object.keys(CRON_MACROS).join(', ')}` }; const parts = expanded.split(/\s+/); if (parts.length !== 5) { return { error: `A cron schedule has five fields (minute hour day-of-month month day-of-week) — got ${parts.length}` }; } const parsed = { expr: raw }; for (let i = 0; i < FIELDS.length; i++) { const spec = FIELDS[i]; const result = parseField(parts[i], spec); if (result.error) return { error: result.error }; parsed[spec.key] = result.values; } // Which of the two day fields BEGIN with `*`, because that decides how they // combine — see matchesDay. Vixie cron counts `*/2` as a star here, not just // the bare `*`, and diverging from that makes pasted crontab lines fire on // days they never named. parsed.domStar = parts[2].trim().startsWith('*'); parsed.dowStar = parts[4].trim().startsWith('*'); return parsed; } export function isValidCron(expr) { return !parseCron(expr).error; } // The one genuinely surprising cron rule: when BOTH day fields are restricted, // a day matches if EITHER does (so `0 0 13 * 5` is the 13th *or* any Friday). // A field beginning with `*` (bare, or a step like `*/2`) is a "star" field, // and a star on either side means both conditions must hold instead — which is // exactly Vixie's rule, and for a bare `*` degenerates to the other field // alone since its set contains every day. function matchesDay(parsed, date) { const domHit = parsed.dom.has(date.getDate()); const dowHit = parsed.dow.has(date.getDay()); if (parsed.domStar || parsed.dowStar) return domHit && dowHit; return domHit || dowHit; } // Four years covers the one expression that legitimately takes years to come // round (Feb 29), and bounds the walk for one that never fires at all — "0 0 30 // 2 *" is syntactically fine and matches no date that will ever exist. const SEARCH_LIMIT_MS = 4 * 366 * 24 * 60 * 60 * 1000; /** * The next time the expression fires, strictly after `from`. * * @returns epoch milliseconds, or null when the expression is invalid or can * never match. Walks whole months/days/hours at a time rather than * minute by minute, so a yearly schedule costs a few hundred steps. */ export function nextCronTime(expr, from = Date.now()) { const parsed = parseCron(expr); if (parsed.error) return null; const limit = from + SEARCH_LIMIT_MS; // Strictly after: a job that just ran at 09:00 must not immediately match // 09:00 again and loop. const at = new Date(from); at.setSeconds(0, 0); at.setMinutes(at.getMinutes() + 1); let previous = -1; while (at.getTime() <= limit) { // Every branch below moves `at` forward, and the limit bounds the walk — // but all of them move it by setting LOCAL fields, and a daylight-saving // transition is exactly where local arithmetic can fail to advance. This // loop runs inside the dispatcher's scan, so it gets a belt as well as // braces: if a step ever fails to move the clock on, give up rather than // spin. (A DST skip is normal cron behaviour: an hour that does not exist // that day simply does not fire.) if (at.getTime() <= previous) return null; previous = at.getTime(); if (!parsed.month.has(at.getMonth() + 1)) { // setMonth(m + 1, 1) rolls the year over on its own. at.setMonth(at.getMonth() + 1, 1); at.setHours(0, 0, 0, 0); continue; } if (!matchesDay(parsed, at)) { at.setDate(at.getDate() + 1); at.setHours(0, 0, 0, 0); continue; } if (!parsed.hour.has(at.getHours())) { at.setHours(at.getHours() + 1, 0, 0, 0); continue; } if (!parsed.minute.has(at.getMinutes())) { at.setMinutes(at.getMinutes() + 1, 0, 0); continue; } return at.getTime(); } return null; } // ISO form, for storing on a job. Null when the expression will never fire // again, so a card can say so instead of showing a date that is a lie. export function nextCronIso(expr, from = Date.now()) { const at = nextCronTime(expr, from); return at === null ? null : new Date(at).toISOString(); } const DAY_NAMES = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays']; function ordinal(n) { const s = n % 100 >= 11 && n % 100 <= 13 ? 'th' : ['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'; return `${n}${s}`; } // Day-of-week field of plain numbers, lists and ranges ("1-5", "1,3,5") as a // sorted set of 0-6, or null for anything else (names, steps, 7-in-a-range). function dowSet(text) { const days = new Set(); for (const part of text.split(',')) { const m = /^(\d)(?:-(\d))?$/.exec(part); if (!m) return null; const from = +m[1], to = m[2] === undefined ? from : +m[2]; if (from > 7 || to > 7 || to < from) return null; for (let d = from; d <= to; d++) days.add(d % 7); } return [...days].sort((a, b) => a - b); } // A schedule in words for a card ("Mondays at 9:00 AM"), or null when the // expression is not one of the common shapes, so the caller shows the raw cron. export function describeCron(expr) { const raw = String(expr || '').trim(); const text = CRON_MACROS[raw.toLowerCase()] || raw; if (!isValidCron(text)) return null; const [min, hour, dom, month, dow] = text.split(/\s+/); if (month !== '*') return null; const num = /^\d+$/; const step = (f) => (f === '*' ? 1 : /^\*\/\d+$/.test(f) ? +f.slice(2) : 0); const every = (n, unit) => (n === 1 ? `Every ${unit}` : `Every ${n} ${unit}s`); if (dom === '*' && dow === '*') { if (hour === '*' && step(min)) return every(step(min), 'minute'); if (num.test(min) && hour === '*') return +min === 0 ? 'Every hour' : `Every hour at :${min.padStart(2, '0')}`; if (num.test(min) && step(hour) > 1) return +min === 0 ? every(step(hour), 'hour') : `${every(step(hour), 'hour')} at :${min.padStart(2, '0')}`; } if (!num.test(min) || !num.test(hour)) return null; const h = +hour, m = +min; const at = h === 0 && m === 0 ? 'midnight' : `${h % 12 || 12}:${String(m).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`; if (dom === '*' && dow === '*') return `Every day at ${at}`; if (dom === '*') { const days = dowSet(dow); if (!days) return null; let who; if (days.join() === '1,2,3,4,5') who = 'Weekdays'; else if (days.join() === '0,6') who = 'Weekends'; else if (days.length === 7) who = 'Every day'; else { const names = days.map(d => DAY_NAMES[d]); who = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; } return `${who} at ${at}`; } if (num.test(dom) && dow === '*') return `Monthly on the ${ordinal(+dom)} at ${at}`; return null; }