add cron expression operation

This commit is contained in:
skywalker 2026-06-06 20:08:03 +08:00
parent d735496641
commit b4e3d5b736
4 changed files with 393 additions and 0 deletions

View File

@ -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"

View 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;

View File

@ -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";

View 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"]
}
]
}
]);