first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import moment from 'moment-timezone';
|
||||
import { type CronExpression, type INode, NodeOperationError, randomInt } from 'n8n-workflow';
|
||||
|
||||
import type { IRecurrenceRule, ScheduleInterval } from './SchedulerInterface';
|
||||
|
||||
export function validateInterval(node: INode, itemIndex: number, interval: ScheduleInterval): void {
|
||||
let errorMessage = '';
|
||||
if (
|
||||
interval.field === 'seconds' &&
|
||||
(interval.secondsInterval > 59 || interval.secondsInterval < 1)
|
||||
) {
|
||||
errorMessage = 'Seconds must be in range 1-59';
|
||||
}
|
||||
if (
|
||||
interval.field === 'minutes' &&
|
||||
(interval.minutesInterval > 59 || interval.minutesInterval < 1)
|
||||
) {
|
||||
errorMessage = 'Minutes must be in range 1-59';
|
||||
}
|
||||
if (interval.field === 'hours' && (interval.hoursInterval > 23 || interval.hoursInterval < 1)) {
|
||||
errorMessage = 'Hours must be in range 1-23';
|
||||
}
|
||||
if (interval.field === 'days' && (interval.daysInterval > 31 || interval.daysInterval < 1)) {
|
||||
errorMessage = 'Days must be in range 1-31';
|
||||
}
|
||||
|
||||
if (interval.field === 'months' && interval.monthsInterval < 1) {
|
||||
errorMessage = 'Months must be larger than 0';
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
throw new NodeOperationError(node, 'Invalid interval', {
|
||||
itemIndex,
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function recurrenceCheck(
|
||||
recurrence: IRecurrenceRule,
|
||||
recurrenceRules: number[],
|
||||
timezone: string,
|
||||
): boolean {
|
||||
if (!recurrence.activated) return true;
|
||||
|
||||
const intervalSize = recurrence.intervalSize;
|
||||
if (!intervalSize) return false;
|
||||
|
||||
const index = recurrence.index;
|
||||
const typeInterval = recurrence.typeInterval;
|
||||
const lastExecution = recurrenceRules[index];
|
||||
|
||||
const momentTz = moment.tz(timezone);
|
||||
if (typeInterval === 'hours') {
|
||||
const hour = momentTz.hour();
|
||||
if (lastExecution === undefined || hour === (intervalSize + lastExecution) % 24) {
|
||||
recurrenceRules[index] = hour;
|
||||
return true;
|
||||
}
|
||||
} else if (typeInterval === 'days') {
|
||||
const dayOfYear = momentTz.dayOfYear();
|
||||
if (lastExecution === undefined || dayOfYear === (intervalSize + lastExecution) % 365) {
|
||||
recurrenceRules[index] = dayOfYear;
|
||||
return true;
|
||||
}
|
||||
} else if (typeInterval === 'weeks') {
|
||||
const week = momentTz.week();
|
||||
if (
|
||||
lastExecution === undefined || // First time executing this rule
|
||||
week === (intervalSize + lastExecution) % 52 || // not first time, but minimum interval has passed
|
||||
week === lastExecution // Trigger on multiple days in the same week
|
||||
) {
|
||||
recurrenceRules[index] = week;
|
||||
return true;
|
||||
}
|
||||
} else if (typeInterval === 'months') {
|
||||
const month = momentTz.month();
|
||||
if (lastExecution === undefined || month === (intervalSize + lastExecution) % 12) {
|
||||
recurrenceRules[index] = month;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const toCronExpression = (interval: ScheduleInterval): CronExpression => {
|
||||
if (interval.field === 'cronExpression') return interval.expression;
|
||||
if (interval.field === 'seconds') return `*/${interval.secondsInterval} * * * * *`;
|
||||
|
||||
const randomSecond = randomInt(0, 60);
|
||||
if (interval.field === 'minutes') return `${randomSecond} */${interval.minutesInterval} * * * *`;
|
||||
|
||||
const minute = interval.triggerAtMinute ?? randomInt(0, 60);
|
||||
if (interval.field === 'hours')
|
||||
return `${randomSecond} ${minute} */${interval.hoursInterval} * * *`;
|
||||
|
||||
// Since Cron does not support `*/` for days or weeks, all following expressions trigger more often, but are then filtered by `recurrenceCheck`
|
||||
const hour = interval.triggerAtHour ?? randomInt(0, 24);
|
||||
if (interval.field === 'days') return `${randomSecond} ${minute} ${hour} * * *`;
|
||||
if (interval.field === 'weeks') {
|
||||
const days = interval.triggerAtDay;
|
||||
const daysOfWeek = days.length === 0 ? '*' : days.join(',');
|
||||
return `${randomSecond} ${minute} ${hour} * * ${daysOfWeek}` as CronExpression;
|
||||
}
|
||||
|
||||
const dayOfMonth = interval.triggerAtDayOfMonth ?? randomInt(0, 31);
|
||||
return `${randomSecond} ${minute} ${hour} ${dayOfMonth} */${interval.monthsInterval} *`;
|
||||
};
|
||||
|
||||
export function intervalToRecurrence(interval: ScheduleInterval, index: number) {
|
||||
let recurrence: IRecurrenceRule = { activated: false };
|
||||
|
||||
if (interval.field === 'hours') {
|
||||
const { hoursInterval } = interval;
|
||||
if (hoursInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: hoursInterval,
|
||||
typeInterval: 'hours',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'days') {
|
||||
const { daysInterval } = interval;
|
||||
if (daysInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: daysInterval,
|
||||
typeInterval: 'days',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'weeks') {
|
||||
const { weeksInterval } = interval;
|
||||
if (weeksInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: weeksInterval,
|
||||
typeInterval: 'weeks',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (interval.field === 'months') {
|
||||
const { monthsInterval } = interval;
|
||||
if (monthsInterval !== 1) {
|
||||
recurrence = {
|
||||
activated: true,
|
||||
index,
|
||||
intervalSize: monthsInterval,
|
||||
typeInterval: 'months',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return recurrence;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.scheduleTrigger",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"categories": ["Core Nodes"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.scheduletrigger/"
|
||||
}
|
||||
],
|
||||
"generic": []
|
||||
},
|
||||
"alias": ["Time", "Scheduler", "Polling", "Cron", "Interval"]
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { sendAt } from 'cron';
|
||||
import moment from 'moment-timezone';
|
||||
import type {
|
||||
ITriggerFunctions,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ITriggerResponse,
|
||||
Cron,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
intervalToRecurrence,
|
||||
recurrenceCheck,
|
||||
toCronExpression,
|
||||
validateInterval,
|
||||
} from './GenericFunctions';
|
||||
import type { IRecurrenceRule, Rule } from './SchedulerInterface';
|
||||
|
||||
export class ScheduleTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Schedule Trigger',
|
||||
name: 'scheduleTrigger',
|
||||
icon: 'fa:clock',
|
||||
group: ['trigger', 'schedule'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Triggers the workflow on a given schedule',
|
||||
eventTriggerDescription: '',
|
||||
activationMessage:
|
||||
'Your schedule trigger will now trigger executions on the schedule you have defined.',
|
||||
defaults: {
|
||||
name: 'Schedule Trigger',
|
||||
color: '#31C49F',
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
properties: [
|
||||
{
|
||||
displayName:
|
||||
"This workflow will run on the schedule you define here once you publish it.<br><br>For testing, you can also trigger it manually: by going back to the canvas and clicking 'execute workflow'",
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger Rules',
|
||||
name: 'rule',
|
||||
placeholder: 'Add Rule',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
default: {
|
||||
interval: [
|
||||
{
|
||||
field: 'days',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'interval',
|
||||
displayName: 'Trigger Interval',
|
||||
builderHint: {
|
||||
message:
|
||||
'You can add multiple intervals to trigger at different times. Use "Custom (Cron)" for more specific scheduling patterns.',
|
||||
},
|
||||
values: [
|
||||
{
|
||||
displayName: 'Trigger Interval',
|
||||
name: 'field',
|
||||
type: 'options',
|
||||
default: 'days',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Seconds',
|
||||
value: 'seconds',
|
||||
},
|
||||
{
|
||||
name: 'Minutes',
|
||||
value: 'minutes',
|
||||
},
|
||||
{
|
||||
name: 'Hours',
|
||||
value: 'hours',
|
||||
},
|
||||
{
|
||||
name: 'Days',
|
||||
value: 'days',
|
||||
},
|
||||
{
|
||||
name: 'Weeks',
|
||||
value: 'weeks',
|
||||
},
|
||||
{
|
||||
name: 'Months',
|
||||
value: 'months',
|
||||
},
|
||||
{
|
||||
name: 'Custom (Cron)',
|
||||
value: 'cronExpression',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Seconds Between Triggers',
|
||||
name: 'secondsInterval',
|
||||
type: 'number',
|
||||
default: 30,
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['seconds'],
|
||||
},
|
||||
},
|
||||
description: 'Number of seconds between each workflow trigger',
|
||||
hint: 'Must be in range 1-59',
|
||||
},
|
||||
{
|
||||
displayName: 'Minutes Between Triggers',
|
||||
name: 'minutesInterval',
|
||||
type: 'number',
|
||||
default: 5,
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['minutes'],
|
||||
},
|
||||
},
|
||||
description: 'Number of minutes between each workflow trigger',
|
||||
hint: 'Must be in range 1-59',
|
||||
},
|
||||
{
|
||||
displayName: 'Hours Between Triggers',
|
||||
name: 'hoursInterval',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['hours'],
|
||||
},
|
||||
},
|
||||
default: 1,
|
||||
description: 'Number of hours between each workflow trigger',
|
||||
hint: 'Must be in range 1-23',
|
||||
},
|
||||
{
|
||||
displayName: 'Days Between Triggers',
|
||||
name: 'daysInterval',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['days'],
|
||||
},
|
||||
},
|
||||
default: 1,
|
||||
description: 'Number of days between each workflow trigger',
|
||||
hint: 'Must be in range 1-31',
|
||||
},
|
||||
{
|
||||
displayName: 'Weeks Between Triggers',
|
||||
name: 'weeksInterval',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['weeks'],
|
||||
},
|
||||
},
|
||||
default: 1,
|
||||
description: 'Would run every week unless specified otherwise',
|
||||
},
|
||||
{
|
||||
displayName: 'Months Between Triggers',
|
||||
name: 'monthsInterval',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['months'],
|
||||
},
|
||||
},
|
||||
default: 1,
|
||||
description: 'Would run every month unless specified otherwise',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger at Day of Month',
|
||||
name: 'triggerAtDayOfMonth',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['months'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 31,
|
||||
},
|
||||
default: 1,
|
||||
description: 'The day of the month to trigger (1-31)',
|
||||
hint: 'If a month doesn’t have this day, the node won’t trigger',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger on Weekdays',
|
||||
name: 'triggerAtDay',
|
||||
type: 'multiOptions',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['weeks'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
maxValue: 7,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Monday',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: 'Tuesday',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: 'Wednesday',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: 'Thursday',
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: 'Friday',
|
||||
value: 5,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Saturday',
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
name: 'Sunday',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
default: [0],
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger at Hour',
|
||||
name: 'triggerAtHour',
|
||||
type: 'options',
|
||||
default: 0,
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['days', 'weeks', 'months'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Midnight',
|
||||
displayName: 'Midnight',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: '1am',
|
||||
displayName: '1am',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: '2am',
|
||||
displayName: '2am',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: '3am',
|
||||
displayName: '3am',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: '4am',
|
||||
displayName: '4am',
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: '5am',
|
||||
displayName: '5am',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: '6am',
|
||||
displayName: '6am',
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
name: '7am',
|
||||
displayName: '7am',
|
||||
value: 7,
|
||||
},
|
||||
{
|
||||
name: '8am',
|
||||
displayName: '8am',
|
||||
value: 8,
|
||||
},
|
||||
{
|
||||
name: '9am',
|
||||
displayName: '9am',
|
||||
value: 9,
|
||||
},
|
||||
{
|
||||
name: '10am',
|
||||
displayName: '10am',
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
name: '11am',
|
||||
displayName: '11am',
|
||||
value: 11,
|
||||
},
|
||||
{
|
||||
name: 'Noon',
|
||||
displayName: 'Noon',
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
name: '1pm',
|
||||
displayName: '1pm',
|
||||
value: 13,
|
||||
},
|
||||
{
|
||||
name: '2pm',
|
||||
displayName: '2pm',
|
||||
value: 14,
|
||||
},
|
||||
{
|
||||
name: '3pm',
|
||||
displayName: '3pm',
|
||||
value: 15,
|
||||
},
|
||||
{
|
||||
name: '4pm',
|
||||
displayName: '4pm',
|
||||
value: 16,
|
||||
},
|
||||
{
|
||||
name: '5pm',
|
||||
displayName: '5pm',
|
||||
value: 17,
|
||||
},
|
||||
{
|
||||
name: '6pm',
|
||||
displayName: '6pm',
|
||||
value: 18,
|
||||
},
|
||||
{
|
||||
name: '7pm',
|
||||
displayName: '7pm',
|
||||
value: 19,
|
||||
},
|
||||
{
|
||||
name: '8pm',
|
||||
displayName: '8pm',
|
||||
value: 20,
|
||||
},
|
||||
{
|
||||
name: '9pm',
|
||||
displayName: '9pm',
|
||||
value: 21,
|
||||
},
|
||||
{
|
||||
name: '10pm',
|
||||
displayName: '10pm',
|
||||
value: 22,
|
||||
},
|
||||
{
|
||||
name: '11pm',
|
||||
displayName: '11pm',
|
||||
value: 23,
|
||||
},
|
||||
],
|
||||
description: 'The hour of the day to trigger',
|
||||
},
|
||||
{
|
||||
displayName: 'Trigger at Minute',
|
||||
name: 'triggerAtMinute',
|
||||
type: 'number',
|
||||
default: 0,
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['hours', 'days', 'weeks', 'months'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 0,
|
||||
maxValue: 59,
|
||||
},
|
||||
description: 'The minute past the hour to trigger (0-59)',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'You can find help generating your cron expression <a href="https://crontab.guru/examples.html" target="_blank">here</a>',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['cronExpression'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Expression',
|
||||
name: 'expression',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'eg. 0 15 * 1 sun',
|
||||
displayOptions: {
|
||||
show: {
|
||||
field: ['cronExpression'],
|
||||
},
|
||||
},
|
||||
hint: 'Format: [Second] [Minute] [Hour] [Day of Month] [Month] [Day of Week]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
|
||||
const version = this.getNode().typeVersion;
|
||||
const { interval: intervals } = this.getNodeParameter('rule', []) as Rule;
|
||||
const timezone = this.getTimezone();
|
||||
const staticData = this.getWorkflowStaticData('node') as {
|
||||
recurrenceRules: number[];
|
||||
};
|
||||
if (!staticData.recurrenceRules) {
|
||||
staticData.recurrenceRules = [];
|
||||
}
|
||||
|
||||
if (version >= 1.3) {
|
||||
for (let i = 0; i < intervals.length; i++) {
|
||||
validateInterval(this.getNode(), i, intervals[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const executeTrigger = (recurrence: IRecurrenceRule) => {
|
||||
const shouldTrigger = recurrenceCheck(recurrence, staticData.recurrenceRules, timezone);
|
||||
if (!shouldTrigger) return;
|
||||
|
||||
const momentTz = moment.tz(timezone);
|
||||
const resultData = {
|
||||
timestamp: momentTz.toISOString(true),
|
||||
'Readable date': momentTz.format('MMMM Do YYYY, h:mm:ss a'),
|
||||
'Readable time': momentTz.format('h:mm:ss a'),
|
||||
'Day of week': momentTz.format('dddd'),
|
||||
Year: momentTz.format('YYYY'),
|
||||
Month: momentTz.format('MMMM'),
|
||||
'Day of month': momentTz.format('DD'),
|
||||
Hour: momentTz.format('HH'),
|
||||
Minute: momentTz.format('mm'),
|
||||
Second: momentTz.format('ss'),
|
||||
Timezone: `${timezone} (UTC${momentTz.format('Z')})`,
|
||||
};
|
||||
|
||||
this.emit([this.helpers.returnJsonArray([resultData])]);
|
||||
};
|
||||
|
||||
const rules = intervals.map((interval, i) => ({
|
||||
interval,
|
||||
cronExpression: toCronExpression(interval),
|
||||
recurrence: intervalToRecurrence(interval, i),
|
||||
}));
|
||||
|
||||
if (this.getMode() !== 'manual') {
|
||||
for (const { interval, cronExpression, recurrence } of rules) {
|
||||
try {
|
||||
const cron: Cron = {
|
||||
expression: cronExpression,
|
||||
recurrence,
|
||||
};
|
||||
this.helpers.registerCron(cron, () => executeTrigger(recurrence));
|
||||
} catch (error) {
|
||||
if (interval.field === 'cronExpression') {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
description: 'More information on how to build them at https://crontab.guru/',
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
} else {
|
||||
const manualTriggerFunction = async () => {
|
||||
const { interval, cronExpression, recurrence } = rules[0];
|
||||
if (interval.field === 'cronExpression') {
|
||||
try {
|
||||
sendAt(cronExpression);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
|
||||
description: 'More information on how to build them at https://crontab.guru/',
|
||||
});
|
||||
}
|
||||
}
|
||||
executeTrigger(recurrence);
|
||||
};
|
||||
|
||||
return { manualTriggerFunction };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CronExpression } from 'n8n-workflow';
|
||||
|
||||
export type IRecurrenceRule =
|
||||
| { activated: false }
|
||||
| {
|
||||
activated: true;
|
||||
index: number;
|
||||
intervalSize: number;
|
||||
typeInterval: 'hours' | 'days' | 'weeks' | 'months';
|
||||
};
|
||||
|
||||
export type ScheduleInterval =
|
||||
| {
|
||||
field: 'cronExpression';
|
||||
expression: CronExpression;
|
||||
}
|
||||
| {
|
||||
field: 'seconds';
|
||||
secondsInterval: number;
|
||||
}
|
||||
| {
|
||||
field: 'minutes';
|
||||
minutesInterval: number;
|
||||
}
|
||||
| {
|
||||
field: 'hours';
|
||||
hoursInterval: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'days';
|
||||
daysInterval: number;
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'weeks';
|
||||
weeksInterval: number;
|
||||
triggerAtDay: number[];
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
}
|
||||
| {
|
||||
field: 'months';
|
||||
monthsInterval: number;
|
||||
triggerAtDayOfMonth?: number;
|
||||
triggerAtHour?: number;
|
||||
triggerAtMinute?: number;
|
||||
};
|
||||
|
||||
export interface Rule {
|
||||
interval: ScheduleInterval[];
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import * as n8nWorkflow from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
intervalToRecurrence,
|
||||
recurrenceCheck,
|
||||
toCronExpression,
|
||||
validateInterval,
|
||||
} from '../GenericFunctions';
|
||||
import type { IRecurrenceRule, ScheduleInterval } from '../SchedulerInterface';
|
||||
|
||||
describe('toCronExpression', () => {
|
||||
Object.defineProperty(n8nWorkflow, 'randomInt', {
|
||||
value: (min: number, max: number) => Math.floor((min + max) / 2),
|
||||
});
|
||||
|
||||
it('should return cron expression for cronExpression field', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'cronExpression',
|
||||
expression: '1 2 3 * * *',
|
||||
});
|
||||
expect(result).toEqual('1 2 3 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for seconds interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'seconds',
|
||||
secondsInterval: 10,
|
||||
});
|
||||
expect(result).toEqual('*/10 * * * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for minutes interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'minutes',
|
||||
minutesInterval: 30,
|
||||
});
|
||||
expect(result).toEqual('30 */30 * * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for hours interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
triggerAtMinute: 22,
|
||||
});
|
||||
expect(result).toEqual('30 22 */3 * * *');
|
||||
|
||||
const result1 = toCronExpression({
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
});
|
||||
expect(result1).toEqual('30 30 */3 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for days interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
triggerAtMinute: 30,
|
||||
triggerAtHour: 10,
|
||||
});
|
||||
expect(result).toEqual('30 30 10 * * *');
|
||||
|
||||
const result1 = toCronExpression({
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 * * *');
|
||||
});
|
||||
|
||||
it('should return cron expression for weeks interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 9,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
});
|
||||
expect(result).toEqual('30 0 9 * * 1,3,5');
|
||||
const result1 = toCronExpression({
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 * * 1,3,5');
|
||||
});
|
||||
|
||||
it('should return cron expression for months interval', () => {
|
||||
const result = toCronExpression({
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 0,
|
||||
triggerAtDayOfMonth: 1,
|
||||
});
|
||||
expect(result).toEqual('30 0 0 1 */3 *');
|
||||
const result1 = toCronExpression({
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
});
|
||||
expect(result1).toEqual('30 30 12 15 */3 *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateInterval', () => {
|
||||
const mockNode: INode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('valid intervals', () => {
|
||||
it.each<[string, ScheduleInterval]>([
|
||||
['seconds', { field: 'seconds', secondsInterval: 1 }],
|
||||
['seconds', { field: 'seconds', secondsInterval: 30 }],
|
||||
['seconds', { field: 'seconds', secondsInterval: 59 }],
|
||||
['minutes', { field: 'minutes', minutesInterval: 1 }],
|
||||
['minutes', { field: 'minutes', minutesInterval: 30 }],
|
||||
['minutes', { field: 'minutes', minutesInterval: 59 }],
|
||||
['hours', { field: 'hours', hoursInterval: 1 }],
|
||||
['hours', { field: 'hours', hoursInterval: 12 }],
|
||||
['hours', { field: 'hours', hoursInterval: 23 }],
|
||||
['days', { field: 'days', daysInterval: 1 }],
|
||||
['days', { field: 'days', daysInterval: 15 }],
|
||||
['days', { field: 'days', daysInterval: 31 }],
|
||||
])('should not throw error for valid %s interval: %j', (_field, interval) => {
|
||||
expect(() => {
|
||||
validateInterval(mockNode, 0, interval);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid intervals', () => {
|
||||
it.each<[string, ScheduleInterval, string]>([
|
||||
['seconds', { field: 'seconds', secondsInterval: 0 }, 'Seconds must be in range 1-59'],
|
||||
['seconds', { field: 'seconds', secondsInterval: 60 }, 'Seconds must be in range 1-59'],
|
||||
['seconds', { field: 'seconds', secondsInterval: -1 }, 'Seconds must be in range 1-59'],
|
||||
['seconds', { field: 'seconds', secondsInterval: 100 }, 'Seconds must be in range 1-59'],
|
||||
['minutes', { field: 'minutes', minutesInterval: 60 }, 'Minutes must be in range 1-59'],
|
||||
['minutes', { field: 'minutes', minutesInterval: 0 }, 'Minutes must be in range 1-59'],
|
||||
['minutes', { field: 'minutes', minutesInterval: -1 }, 'Minutes must be in range 1-59'],
|
||||
['minutes', { field: 'minutes', minutesInterval: 100 }, 'Minutes must be in range 1-59'],
|
||||
['hours', { field: 'hours', hoursInterval: 0 }, 'Hours must be in range 1-23'],
|
||||
['hours', { field: 'hours', hoursInterval: 24 }, 'Hours must be in range 1-23'],
|
||||
['hours', { field: 'hours', hoursInterval: -1 }, 'Hours must be in range 1-23'],
|
||||
['hours', { field: 'hours', hoursInterval: 100 }, 'Hours must be in range 1-23'],
|
||||
['days', { field: 'days', daysInterval: 0 }, 'Days must be in range 1-31'],
|
||||
['days', { field: 'days', daysInterval: 32 }, 'Days must be in range 1-31'],
|
||||
['days', { field: 'days', daysInterval: -1 }, 'Days must be in range 1-31'],
|
||||
['days', { field: 'days', daysInterval: 100 }, 'Days must be in range 1-31'],
|
||||
['months', { field: 'months', monthsInterval: 0 }, 'Months must be larger than 0'],
|
||||
])(
|
||||
'should throw error for invalid %s interval: %j',
|
||||
(_field, interval, expectedDescription) => {
|
||||
try {
|
||||
validateInterval(mockNode, 0, interval);
|
||||
fail('Expected validateInterval to throw an error');
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('Invalid interval');
|
||||
expect(error.description).toBe(expectedDescription);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recurrenceCheck', () => {
|
||||
it('should return true if activated=false', () => {
|
||||
const result = recurrenceCheck({ activated: false }, [], 'UTC');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if intervalSize is falsey', () => {
|
||||
const result = recurrenceCheck(
|
||||
{
|
||||
activated: true,
|
||||
index: 0,
|
||||
intervalSize: 0,
|
||||
typeInterval: 'days',
|
||||
},
|
||||
[],
|
||||
'UTC',
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true only once for a day cron', () => {
|
||||
const recurrence: IRecurrenceRule = {
|
||||
activated: true,
|
||||
index: 0,
|
||||
intervalSize: 2,
|
||||
typeInterval: 'days',
|
||||
};
|
||||
const recurrenceRules: number[] = [];
|
||||
const result1 = recurrenceCheck(recurrence, recurrenceRules, 'UTC');
|
||||
expect(result1).toBe(true);
|
||||
const result2 = recurrenceCheck(recurrence, recurrenceRules, 'UTC');
|
||||
expect(result2).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('intervalToRecurrence', () => {
|
||||
it('should return recurrence rule for seconds interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'seconds',
|
||||
secondsInterval: 10,
|
||||
},
|
||||
0,
|
||||
);
|
||||
expect(result.activated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return recurrence rule for minutes interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'minutes',
|
||||
minutesInterval: 30,
|
||||
},
|
||||
1,
|
||||
);
|
||||
expect(result.activated).toBe(false);
|
||||
});
|
||||
|
||||
it('should return recurrence rule for hours interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
triggerAtMinute: 22,
|
||||
},
|
||||
2,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 2,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'hours',
|
||||
});
|
||||
|
||||
const result1 = intervalToRecurrence(
|
||||
{
|
||||
field: 'hours',
|
||||
hoursInterval: 3,
|
||||
},
|
||||
3,
|
||||
);
|
||||
expect(result1).toEqual({
|
||||
activated: true,
|
||||
index: 3,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'hours',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for days interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
triggerAtMinute: 30,
|
||||
triggerAtHour: 10,
|
||||
},
|
||||
4,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 4,
|
||||
intervalSize: 4,
|
||||
typeInterval: 'days',
|
||||
});
|
||||
|
||||
const result1 = intervalToRecurrence(
|
||||
{
|
||||
field: 'days',
|
||||
daysInterval: 4,
|
||||
},
|
||||
5,
|
||||
);
|
||||
expect(result1).toEqual({
|
||||
activated: true,
|
||||
index: 5,
|
||||
intervalSize: 4,
|
||||
typeInterval: 'days',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for weeks interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'weeks',
|
||||
weeksInterval: 2,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 9,
|
||||
triggerAtDay: [1, 3, 5],
|
||||
},
|
||||
6,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 6,
|
||||
intervalSize: 2,
|
||||
typeInterval: 'weeks',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return recurrence rule for months interval', () => {
|
||||
const result = intervalToRecurrence(
|
||||
{
|
||||
field: 'months',
|
||||
monthsInterval: 3,
|
||||
triggerAtMinute: 0,
|
||||
triggerAtHour: 0,
|
||||
triggerAtDayOfMonth: 1,
|
||||
},
|
||||
8,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activated: true,
|
||||
index: 8,
|
||||
intervalSize: 3,
|
||||
typeInterval: 'months',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import * as n8nWorkflow from 'n8n-workflow';
|
||||
|
||||
import { testTriggerNode } from '@test/nodes/TriggerHelpers';
|
||||
|
||||
import { ScheduleTrigger } from '../ScheduleTrigger.node';
|
||||
|
||||
describe('ScheduleTrigger', () => {
|
||||
Object.defineProperty(n8nWorkflow, 'randomInt', {
|
||||
value: (min: number, max: number) => Math.floor((min + max) / 2),
|
||||
});
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const mockDate = new Date('2023-12-28 12:34:56.789Z');
|
||||
const timezone = 'Europe/Berlin';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('should emit on defined schedule', async () => {
|
||||
const { emit } = await testTriggerNode(ScheduleTrigger, {
|
||||
timezone,
|
||||
node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } },
|
||||
workflowStaticData: { recurrenceRules: [] },
|
||||
});
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(HOUR);
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const firstTriggerData = emit.mock.calls[0][0][0][0];
|
||||
expect(firstTriggerData.json).toEqual({
|
||||
'Day of month': '28',
|
||||
'Day of week': 'Thursday',
|
||||
Hour: '15',
|
||||
Minute: '30',
|
||||
Month: 'December',
|
||||
'Readable date': 'December 28th 2023, 3:30:30 pm',
|
||||
'Readable time': '3:30:30 pm',
|
||||
Second: '30',
|
||||
Timezone: 'Europe/Berlin (UTC+01:00)',
|
||||
Year: '2023',
|
||||
timestamp: '2023-12-28T15:30:30.000+01:00',
|
||||
});
|
||||
|
||||
jest.setSystemTime(new Date(firstTriggerData.json.timestamp as string));
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(HOUR);
|
||||
expect(emit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should emit on schedule defined as a cron expression', async () => {
|
||||
const { emit } = await testTriggerNode(ScheduleTrigger, {
|
||||
timezone,
|
||||
node: {
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{
|
||||
field: 'cronExpression',
|
||||
expression: '0 */2 * * *', // every 2 hours
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
workflowStaticData: {},
|
||||
});
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(2 * HOUR);
|
||||
expect(emit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw on invalid cron expressions', async () => {
|
||||
await expect(
|
||||
testTriggerNode(ScheduleTrigger, {
|
||||
timezone,
|
||||
node: {
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{
|
||||
field: 'cronExpression',
|
||||
expression: '100 * * * *', // minute should be 0-59 -> invalid
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
workflowStaticData: {},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(n8nWorkflow.NodeOperationError);
|
||||
});
|
||||
|
||||
it('should emit when manually executed', async () => {
|
||||
const { emit, manualTriggerFunction } = await testTriggerNode(ScheduleTrigger, {
|
||||
mode: 'manual',
|
||||
timezone,
|
||||
node: { parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 3 }] } } },
|
||||
workflowStaticData: { recurrenceRules: [] },
|
||||
});
|
||||
|
||||
await manualTriggerFunction?.();
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
|
||||
const firstTriggerData = emit.mock.calls[0][0][0][0];
|
||||
expect(firstTriggerData.json).toEqual({
|
||||
'Day of month': '28',
|
||||
'Day of week': 'Thursday',
|
||||
Hour: '13',
|
||||
Minute: '34',
|
||||
Month: 'December',
|
||||
'Readable date': 'December 28th 2023, 1:34:56 pm',
|
||||
'Readable time': '1:34:56 pm',
|
||||
Second: '56',
|
||||
Timezone: 'Europe/Berlin (UTC+01:00)',
|
||||
Year: '2023',
|
||||
timestamp: '2023-12-28T13:34:56.789+01:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on invalid cron expressions in manual mode', async () => {
|
||||
const { manualTriggerFunction } = await testTriggerNode(ScheduleTrigger, {
|
||||
mode: 'manual',
|
||||
timezone,
|
||||
node: {
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{
|
||||
field: 'cronExpression',
|
||||
expression: '@daily *', // adding extra fields to shorthand not allowed -> invalid
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
workflowStaticData: {},
|
||||
});
|
||||
await expect(manualTriggerFunction?.()).rejects.toBeInstanceOf(
|
||||
n8nWorkflow.NodeOperationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user