add cron expression operation
This commit is contained in:
parent
d735496641
commit
b4e3d5b736
@ -366,6 +366,7 @@
|
|||||||
"Windows Filetime to UNIX Timestamp",
|
"Windows Filetime to UNIX Timestamp",
|
||||||
"UNIX Timestamp to Windows Filetime",
|
"UNIX Timestamp to Windows Filetime",
|
||||||
"DateTime Delta",
|
"DateTime Delta",
|
||||||
|
"Cron Expression",
|
||||||
"Extract dates",
|
"Extract dates",
|
||||||
"Get Time",
|
"Get Time",
|
||||||
"Sleep"
|
"Sleep"
|
||||||
|
|||||||
312
src/core/operations/CronExpression.mjs
Normal file
312
src/core/operations/CronExpression.mjs
Normal file
@ -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;
|
||||||
@ -60,6 +60,7 @@ import "./tests/ConvertCoordinateFormat.mjs";
|
|||||||
import "./tests/ConvertLeetSpeak.mjs";
|
import "./tests/ConvertLeetSpeak.mjs";
|
||||||
import "./tests/ConvertToNATOAlphabet.mjs";
|
import "./tests/ConvertToNATOAlphabet.mjs";
|
||||||
import "./tests/CRCChecksum.mjs";
|
import "./tests/CRCChecksum.mjs";
|
||||||
|
import "./tests/CronExpression.mjs";
|
||||||
import "./tests/Crypt.mjs";
|
import "./tests/Crypt.mjs";
|
||||||
import "./tests/CSV.mjs";
|
import "./tests/CSV.mjs";
|
||||||
import "./tests/DateTime.mjs";
|
import "./tests/DateTime.mjs";
|
||||||
|
|||||||
79
tests/operations/tests/CronExpression.mjs
Normal file
79
tests/operations/tests/CronExpression.mjs
Normal file
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
Loading…
x
Reference in New Issue
Block a user