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",
"UNIX Timestamp to Windows Filetime",
"DateTime Delta",
"Cron Expression",
"Extract dates",
"Get Time",
"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/ConvertToNATOAlphabet.mjs";
import "./tests/CRCChecksum.mjs";
import "./tests/CronExpression.mjs";
import "./tests/Crypt.mjs";
import "./tests/CSV.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"]
}
]
}
]);