frontend_nyla/src/components/FlowEditor/nodes/runtime/scheduleCron.ts
2026-04-07 00:49:12 +02:00

296 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* User-friendly schedule ↔ cron
* Standard: 5 Felder (minute hour dom month dow), DOW 0=So … 6=Sa
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
*/
export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06, cron DOW; nur bei mode === 'weekly' */
weekdays: number[];
/** Monatlich: Tag 131; Jährlich: Tag im gewählten Monat */
monthDay: number;
/** 112, nur bei calendar + yearly */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cronDow wie oben) */
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
{ cronDow: 1, label: 'Mo' },
{ cronDow: 2, label: 'Di' },
{ cronDow: 3, label: 'Mi' },
{ cronDow: 4, label: 'Do' },
{ cronDow: 5, label: 'Fr' },
{ cronDow: 6, label: 'Sa' },
{ cronDow: 0, label: 'So' },
];
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'daily',
hour: 8,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 15,
intervalUnit: 'minutes',
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
export function buildCronFromSpec(spec: ScheduleSpec): string {
const m = clamp(Math.floor(spec.minute), 0, 59);
const h = clamp(Math.floor(spec.hour), 0, 23);
switch (spec.mode) {
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((d) => d >= 0 && d <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'calendar': {
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
if (spec.calendarPeriod === 'monthly') {
return `${m} ${h} ${dom} * *`;
}
const month = clamp(Math.floor(spec.monthIndex), 1, 12);
return `${m} ${h} ${dom} ${month} *`;
}
case 'interval': {
const v = Math.max(1, Math.floor(spec.intervalValue));
switch (spec.intervalUnit) {
case 'seconds': {
const s = clamp(v, 1, 59);
return `*/${s} * * * * *`;
}
case 'minutes': {
const mm = clamp(v, 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(v, 1, 23);
return `0 */${hh} * * *`;
}
case 'days': {
if (v <= 1) return `0 0 * * *`;
const d = clamp(v, 2, 31);
return `0 0 */${d} * *`;
}
case 'years':
default:
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
if (!cron || typeof cron !== 'string') return null;
const p = cron.trim().split(/\s+/);
if (p.length === 6) {
const [secS, minS, hourS, domS, monthS, dowS] = p;
if (
secS.startsWith('*/') &&
minS === '*' &&
hourS === '*' &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(secS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'seconds',
minute: 0,
hour: 0,
};
}
}
return null;
}
if (p.length < 5) return null;
const [minS, hourS, domS, monthS, dowS] = p;
const minute = parseInt(minS, 10);
const hour = parseInt(hourS, 10);
if (Number.isNaN(minute) || Number.isNaN(hour)) return null;
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'minutes',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'hours',
minute: 0,
hour: 0,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'interval',
intervalValue: iv,
intervalUnit: 'days',
minute: 0,
hour: 0,
};
}
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute };
}
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
const parts = dowS.split(',').map((x) => parseInt(x.trim(), 10));
const days = parts.filter((x) => !Number.isNaN(x) && x >= 0 && x <= 7);
if (days.length > 0) {
const norm = days.map((d) => (d === 7 ? 0 : d));
return {
...defaultScheduleSpec(),
mode: 'weekly',
hour,
minute,
weekdays: norm,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'monthly',
hour,
minute,
monthDay: dom,
};
}
if (
!Number.isNaN(dom) &&
dom >= 1 &&
dom <= 31 &&
!Number.isNaN(month) &&
month >= 1 &&
month <= 12 &&
(dowS === '*' || dowS === '?')
) {
return {
...defaultScheduleSpec(),
mode: 'calendar',
calendarPeriod: 'yearly',
hour,
minute,
monthDay: dom,
monthIndex: month,
};
}
return null;
}
const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval'];
function normalizeIntervalUnit(u: unknown): IntervalUnit {
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
return 'minutes';
}
/** Liest Spec aus Node-Parametern (schedule-Objekt bevorzugt, sonst Cron parsen) */
export function scheduleSpecFromParams(params: Record<string, unknown>): ScheduleSpec {
const raw = params.schedule;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const o = raw as Record<string, unknown>;
let mode = o.mode as string;
if (mode === 'monthly') {
mode = 'calendar';
}
if (VALID_MODES.includes(mode as ScheduleMode)) {
const base = defaultScheduleSpec();
let calendarPeriod: CalendarPeriod = base.calendarPeriod;
if (mode === 'calendar') {
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
}
return {
mode: mode as ScheduleMode,
hour: clamp(Number(o.hour) || base.hour, 0, 23),
minute: clamp(Number(o.minute) || base.minute, 0, 59),
weekdays: Array.isArray(o.weekdays)
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
: base.weekdays,
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
calendarPeriod,
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
};
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
return parseCronToSpec(cron) ?? defaultScheduleSpec();
}