diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index d3e7648a..0d9d943b 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -366,6 +366,7 @@ "Windows Filetime to UNIX Timestamp", "UNIX Timestamp to Windows Filetime", "DateTime Delta", + "Cron Expression", "Extract dates", "Get Time", "Sleep" diff --git a/src/core/operations/CronExpression.mjs b/src/core/operations/CronExpression.mjs new file mode 100644 index 00000000..b5ee6341 --- /dev/null +++ b/src/core/operations/CronExpression.mjs @@ -0,0 +1,312 @@ +/** + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import moment from "moment-timezone"; + +const FIELD_CONFIGS = [ + { + title: "Minute", + min: 0, + max: 59, + singular: "minute", + plural: "minutes", + summaryPrefix: "At", + detailPrefix: "at" + }, + { + title: "Hour", + min: 0, + max: 23, + singular: "hour", + plural: "hours", + summaryPrefix: "past", + detailPrefix: "at" + }, + { + title: "Day of month", + min: 1, + max: 31, + singular: "day-of-month", + plural: "days-of-month", + summaryPrefix: "on", + detailPrefix: "on" + }, + { + title: "Month", + min: 1, + max: 12, + singular: "month", + plural: "months", + summaryPrefix: "in", + detailPrefix: "in" + }, + { + title: "Day of week", + min: 0, + max: 7, + singular: "day-of-week", + plural: "days-of-week", + summaryPrefix: "on", + detailPrefix: "on" + } +]; + +/** + * Cron Expression operation + */ +class CronExpression extends Operation { + + /** + * CronExpression constructor + */ + constructor() { + super(); + + this.name = "Cron Expression"; + this.module = "Default"; + this.description = "Explains standard five-field cron expressions and can generate a five-field cron expression from a DateTime value. Supports comma-separated values, ranges, wildcards and step values."; + this.infoURL = "https://wikipedia.org/wiki/Cron"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + name: "Mode", + type: "option", + value: ["Explain expression", "Generate expression"] + }, + { + name: "Input date format", + type: "binaryString", + value: "YYYY-MM-DD HH:mm" + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const mode = args[0], + inputFormat = args[1]; + + switch (mode) { + case "Explain expression": + return explainExpression(input); + case "Generate expression": + return generateExpression(input, inputFormat); + default: + throw new OperationError("Unknown Cron Expression mode"); + } + } + +} + +/** + * @param {string} input + * @returns {string} + */ +function explainExpression(input) { + const expression = input.trim().replace(/\s+/g, " "); + if (!expression) { + throw new OperationError("Enter a five-field cron expression"); + } + + const fields = expression.split(" "); + if (fields.length !== FIELD_CONFIGS.length) { + throw new OperationError("Cron expressions must contain five fields: minute hour day-of-month month day-of-week"); + } + + const descriptions = fields.map((field, index) => describeField(field, FIELD_CONFIGS[index])), + summaryParts = descriptions + .filter(description => description.includeInSummary) + .map(description => `${description.config.summaryPrefix} ${description.summary}`); + + return [ + summaryParts.join(" ") + ".", + "", + ...descriptions.map(description => `${description.config.title}: ${description.config.detailPrefix} ${description.detail}`) + ].join("\n"); +} + +/** + * @param {string} input + * @param {string} inputFormat + * @returns {string} + */ +function generateExpression(input, inputFormat) { + const date = moment(input.trim(), inputFormat, true); + + if (!date.isValid()) { + throw new OperationError("Input does not match the configured date format"); + } + + return [ + date.minute(), + date.hour(), + date.date(), + date.month() + 1, + "*" + ].join(" "); +} + +/** + * @param {string} field + * @param {Object} config + * @returns {Object} + */ +function describeField(field, config) { + validateFieldSyntax(field); + + if (field === "*") { + return { + config, + summary: `every ${config.singular}`, + detail: `every ${config.singular}`, + includeInSummary: config.title !== "Day of week" + }; + } + + const parts = field.split(",").map(part => describePart(part, config)); + + return { + config, + summary: joinParts(parts.map(part => part.summary)), + detail: joinParts(parts.map(part => part.detail)), + includeInSummary: true + }; +} + +/** + * @param {string} part + * @param {Object} config + * @returns {Object} + */ +function describePart(part, config) { + const [rangePart, stepPart] = part.split("/"); + + if (stepPart !== undefined) { + const step = parseNumber(stepPart, config); + + if (step < 1) { + throw new OperationError(`${config.title} step must be a positive integer`); + } + + if (rangePart === "*") { + return { + summary: `every ${ordinal(step)} ${config.singular}`, + detail: `every ${ordinal(step)} ${config.singular}` + }; + } + + const range = parseRange(rangePart, config); + + return { + summary: `every ${ordinal(step)} ${config.singular} from ${range.start} through ${range.end}`, + detail: `every ${ordinal(step)} ${config.singular} from ${range.start} through ${range.end}` + }; + } + + if (rangePart.includes("-")) { + const range = parseRange(rangePart, config); + + return { + summary: `${config.plural} ${range.start} through ${range.end}`, + detail: `${config.plural} ${range.start} through ${range.end}` + }; + } + + const value = parseNumber(rangePart, config); + validateBounds(value, config); + + return { + summary: `${config.singular} ${value}`, + detail: `${config.singular} ${value}` + }; +} + +/** + * @param {string} part + */ +function validateFieldSyntax(part) { + const atomPattern = "(?:\\d+(?:-\\d+)?(?:\\/\\d+)?|\\*\\/\\d+)", + fieldPattern = new RegExp(`^(?:\\*|${atomPattern}(?:,${atomPattern})*)$`); + + if (!fieldPattern.test(part)) { + throw new OperationError(`Unsupported cron field: ${part}`); + } +} + +/** + * @param {string} part + * @param {Object} config + * @returns {Object} + */ +function parseRange(part, config) { + const [start, end] = part.split("-").map(value => parseNumber(value, config)); + + validateBounds(start, config); + validateBounds(end, config); + + if (start > end) { + throw new OperationError(`${config.title} range start cannot be greater than range end`); + } + + return {start, end}; +} + +/** + * @param {string} value + * @param {Object} config + * @returns {number} + */ +function parseNumber(value, config) { + const number = Number(value); + + if (!Number.isInteger(number)) { + throw new OperationError(`${config.title} values must be integers`); + } + + return number; +} + +/** + * @param {number} value + * @param {Object} config + */ +function validateBounds(value, config) { + if (value < config.min || value > config.max) { + throw new OperationError(`${config.title} values must be between ${config.min} and ${config.max}`); + } +} + +/** + * @param {string[]} parts + * @returns {string} + */ +function joinParts(parts) { + if (parts.length === 1) return parts[0]; + return parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1]; +} + +/** + * @param {number} value + * @returns {string} + */ +function ordinal(value) { + const suffix = value % 100 >= 11 && value % 100 <= 13 ? "th" : { + 1: "st", + 2: "nd", + 3: "rd" + }[value % 10] || "th"; + + return value + suffix; +} + +export default CronExpression; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 44792560..323b58ae 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -60,6 +60,7 @@ import "./tests/ConvertCoordinateFormat.mjs"; import "./tests/ConvertLeetSpeak.mjs"; import "./tests/ConvertToNATOAlphabet.mjs"; import "./tests/CRCChecksum.mjs"; +import "./tests/CronExpression.mjs"; import "./tests/Crypt.mjs"; import "./tests/CSV.mjs"; import "./tests/DateTime.mjs"; diff --git a/tests/operations/tests/CronExpression.mjs b/tests/operations/tests/CronExpression.mjs new file mode 100644 index 00000000..45a9c509 --- /dev/null +++ b/tests/operations/tests/CronExpression.mjs @@ -0,0 +1,79 @@ +/** + * Cron Expression tests. + * + * @author skyswordw + * + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "Cron Expression: explain list and step expression", + input: "0 0,12 1 */2 *", + expectedOutput: `At minute 0 past hour 0 and hour 12 on day-of-month 1 in every 2nd month. + +Minute: at minute 0 +Hour: at hour 0 and hour 12 +Day of month: on day-of-month 1 +Month: in every 2nd month +Day of week: on every day-of-week`, + recipeConfig: [ + { + op: "Cron Expression", + args: ["Explain expression", "YYYY-MM-DD HH:mm"] + } + ] + }, + { + name: "Cron Expression: explain range expression", + input: "*/15 9-17 * * 1-5", + expectedOutput: `At every 15th minute past hours 9 through 17 on every day-of-month in every month on days-of-week 1 through 5. + +Minute: at every 15th minute +Hour: at hours 9 through 17 +Day of month: on every day-of-month +Month: in every month +Day of week: on days-of-week 1 through 5`, + recipeConfig: [ + { + op: "Cron Expression", + args: ["Explain expression", "YYYY-MM-DD HH:mm"] + } + ] + }, + { + name: "Cron Expression: generate from date", + input: "2026-06-06 13:45", + expectedOutput: "45 13 6 6 *", + recipeConfig: [ + { + op: "Cron Expression", + args: ["Generate expression", "YYYY-MM-DD HH:mm"] + } + ] + }, + { + name: "Cron Expression: invalid field count", + input: "* * *", + expectedOutput: "Cron expressions must contain five fields: minute hour day-of-month month day-of-week", + recipeConfig: [ + { + op: "Cron Expression", + args: ["Explain expression", "YYYY-MM-DD HH:mm"] + } + ] + }, + { + name: "Cron Expression: invalid bounds", + input: "60 * * * *", + expectedOutput: "Minute values must be between 0 and 59", + recipeConfig: [ + { + op: "Cron Expression", + args: ["Explain expression", "YYYY-MM-DD HH:mm"] + } + ] + } +]);