Ctrl K
ComunidadGuías…

lib/cron.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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;
}