UNPKG

cronnor

Version:

Bibliothèque JavaScript implémentant un programme cron.

335 lines (316 loc) 10.6 kB
/** * @module * @license MIT * @author Sébastien Règne */ import Field from "./field.js"; /** * Les chaines spéciales avec leur équivalent. * * @type {Object<string, string>} */ const NICKNAMES = { "@yearly": "0 0 0 1 1 *", "@annually": "0 0 0 1 1 *", "@monthly": "0 0 0 1 * *", "@weekly": "0 0 0 * * 0", "@daily": "0 0 0 * * *", "@midnight": "0 0 0 * * *", "@hourly": "0 0 * * * *", }; /** * Les formes littérales des mois et des jours de la semaine avec leur * équivalent numérique. Les autres champs (secondes, minutes, heures et jour du * mois) n'en ont pas. * * @type {Object<string, number>[]} */ const BASE_NAMES = [ // Secondes. {}, // Minutes. {}, // Heures. {}, // Jour du mois. {}, // Mois. { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }, // Jour de la semaine. { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, }, ]; /** * Les formes littérales des mois et des jours de la semaine avec leur * équivalent numérique pour la borne minimum et maximum. La seule différence * entre le minimum et la maximum est pour le dimanche qui utilise le nombre `0` * ou `7`. * * @type {Object<string, Object<string, number>[]>} */ const NAMES = { MIN: BASE_NAMES, MAX: BASE_NAMES.map((n) => ("sun" in n ? { ...n, sun: 7 } : { ...n })), }; /** * Les valeurs minimales et maximales (incluses) saisissables dans les cinq * champs (pour des valeurs simples ou des intervalles). * * @type {Object<string, number[]>} */ const LIMITS = { // Secondes, minutes, heures, jour du mois, mois, jour de la semaine. MIN: [0, 0, 0, 1, 1, 0], MAX: [59, 59, 23, 31, 12, 7], }; /** * Le pas par défaut pour les intervalles. * * @type {Object<string, number>} */ const DEFAULT_STEPS = { NORMAL: 1, // Utiliser un grand nombre pour que l'algorithme sélectionne une seule // valeur (aléatoire) dans l'intervalle. RANDOM: Number.MAX_SAFE_INTEGER, }; /** * Le nombre maximum de jours pour chaque mois. * * @type {number[]} */ const MAX_DAYS_IN_MONTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; /** * Le message d'erreur (qui sera suivi de l'expression _cron_) envoyé dans * l'exception. * * @type {string} */ const ERROR = "Syntax error, unrecognized expression: "; /** * Les formats des sous-champs d'un motif. * * @type {any[][]} */ const FORMATS = [ ["*", { extra: { restricted: false } }], ["*/{step}"], ["{min}", { max: "=" }], ["{min}-{max}"], ["{min}-{max}/{step}"], ["~", { extra: { random: true } }], ["~/{step}", { extra: { random: true } }], ["{min}~", { extra: { random: true } }], ["{min}~/{step}", { extra: { random: true } }], ["~{max}", { extra: { random: true } }], ["~{max}/{step}", { extra: { random: true } }], ["{min}~{max}", { extra: { random: true } }], ["{min}~{max}/{step}", { extra: { random: true } }], ].map(([format, complements]) => [ new RegExp( "^" + /** @type {string} */ (format) .replaceAll("*", String.raw`\*`) .replaceAll("{step}", String.raw`(?<step>\d+)`) .replaceAll("{min}", String.raw`(?<min>[\da-z?]+)`) .replaceAll("{max}", String.raw`(?<max>[\da-z?]+)`) + "$", "iv", ), complements, ]); /** * Retourne la valeur d'une date en fonction de l'index du champ. * * @param {Date} date La date. * @param {number} index L'index du champ. * @param {boolean} [max] La marque indiquant si la valeur est pour le maximum * (utilisé pour le jour de la semaine). * @returns {number} La valeur du champ. * @throws {TypeError} Si l'index est invalide. */ const getIndexValue = (date, index, max = false) => { switch (index) { case 0: return date.getSeconds(); case 1: return date.getMinutes(); case 2: return date.getHours(); case 3: return date.getDate(); case 4: // Incrémenter d'un pour faire commencer les mois à un. return date.getMonth() + 1; case 5: // Utiliser le nombre sept pour le dimanche quand il est placé dans // la borne supérieure. return max && 0 === date.getDay() ? 7 : date.getDay(); // Stryker disable next-line all: Désactiver Stryker pour le défaut, car // la fonction getIndexValue() est toujours appelée avec un index entre // 0 et 5. Il est donc impossible de tester cette condition. default: // Stryker disable next-line all throw new TypeError(`Invalid index ${index}`); } }; /** * Parse le sous-champ d'un motif. * * @param {Object} parts Les données du pseudo-intervalle. * @param {string} [parts.min] La valeur minimale du * pseudo-intervalle. * @param {string} [parts.max] La valeur maximale du * pseudo-intervalle. * @param {string} [parts.step] Le pas du pseudo-intervalle. * @param {Object} [parts.extra] Les informations supplémentaires. * @param {boolean} [parts.extra.restricted] `true` (par défaut) pour un champ * qui était différent de `"*"` ; * sinon `false`. * @param {boolean} [parts.extra.random] `false` (par défaut) pour générer * un nombre aléatoire pour le * minimum. * @param {number} index L'index du champ. * @param {Date} now La date courante. * @param {string} pattern Le motif complet. * @returns {Field} Le sous-champ du motif. * @throws {Error} Si la syntaxe du sous-champ est incorrecte. * @throws {RangeError} Si l'intervalle est invalide (hors limite ou quand la * borne supérieure est plus petite que la borne * inférieure). */ const parseField = (parts, index, now, pattern) => { let min; if (undefined === parts.min) { min = LIMITS.MIN[index]; } else if ("?" === parts.min) { min = getIndexValue(now, index); } else if (parts.min.toLowerCase() in NAMES.MIN[index]) { min = NAMES.MIN[index][parts.min.toLowerCase()]; } else if (/^\d+$/v.test(parts.min)) { min = Number(parts.min); } else { throw new Error(ERROR + pattern); } let max; if (undefined === parts.max) { max = LIMITS.MAX[index]; } else if ("=" === parts.max) { max = min; } else if ("?" === parts.max) { max = getIndexValue(now, index, true); } else if (parts.max.toLowerCase() in NAMES.MAX[index]) { max = NAMES.MAX[index][parts.max.toLowerCase()]; } else if (/^\d+$/v.test(parts.max)) { max = Number(parts.max); } else { throw new Error(ERROR + pattern); } const step = undefined === parts.step ? parts.extra?.random ? DEFAULT_STEPS.RANDOM : DEFAULT_STEPS.NORMAL : Number(parts.step); const extra = { random: false, restricted: true, ...parts.extra, }; // Vérifier que les valeurs sont dans les intervalles. if ( min < LIMITS.MIN[index] || LIMITS.MAX[index] < max || max < min || 0 === step ) { throw new RangeError(ERROR + pattern); } return Field.range(min, max, step, extra); }; /** * Extrait les valeurs d'une expression _cron_. * * @param {string} pattern Le motif de l'expression _cron_ * @returns {Object<string, Field>} Les valeurs de l'expression _cron_ pour * chaque champ. * @throws {Error} Si la syntaxe du motif est incorrecte. * @throws {RangeError} Si un intervalle est invalide (hors limite ou quand la * borne supérieure est plus petite que la borne * inférieure). */ export default function parse(pattern) { // Remplacer l'éventuelle chaine spéciale par son équivalent et séparer // les cinq ou six champs (secondes, minutes, heures, jour du mois, mois et // jour de la semaine). const fields = (NICKNAMES[pattern.toLowerCase()] ?? pattern).split(/\s+/v); if (5 === fields.length) { // Ajouter la valeur "0" pour les secondes (car elles ne sont pas // renseignées). fields.unshift("0"); } else if (6 !== fields.length) { throw new Error(ERROR + pattern); } // Figer la date courante pour remplacer tous les éventuels "?" par les // mêmes valeurs. const now = new Date(); // Parcourir les six champs. const [seconds, minutes, hours, date, month, day] = fields.map( (field, index) => Field.flat( // Parcourir les sous-champs. field.split(",").map((subfield) => { for (const [format, complements] of FORMATS) { const result = format.exec(subfield); if (null !== result) { const parts = { ...result.groups, ...complements, }; return parseField(parts, index, now, pattern); } } throw new Error(ERROR + pattern); }), ), ); // Récupérer le nombre maximum de jours du mois le plus long parmi tous // les mois autorisés. const max = Math.max( // Décrémenter d'un pour faire commencer les mois à zéro. ...month.values().map((m) => MAX_DAYS_IN_MONTHS[m - 1]), ); if (max < date.min) { throw new RangeError(ERROR + pattern); } return { seconds, minutes, hours, date, // Faire commencer les mois à zéro. month: month.map((v) => v - 1), // Toujours utiliser zéro pour le dimanche. day: day.map((v) => (7 === v ? 0 : v)), }; }