ui-nyla/src/utils/scheduleCron.ts

531 lines
15 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)
*/
/** Primary planner modes (+ legacy aliases still read/written). */
export type ScheduleMode =
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| 'custom'
| 'daily'
| 'weekdays'
| 'weekly'
| 'calendar'
| 'interval';
export type CalendarPeriod = 'monthly' | 'yearly';
/** sek, min, h, T (Tage), a (Jahre) — legacy interval mode */
export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
export interface ScheduleSpec {
mode: ScheduleMode;
hour: number;
minute: number;
/** 06 cron DOW; für weeks / weekly / weekdays */
weekdays: number[];
/** Tag des Monats 131 (Planner months: 128 empfohlen) */
monthDay: number;
/** 112, nur bei calendar + yearly (Legacy) */
monthIndex: number;
calendarPeriod: CalendarPeriod;
intervalValue: number;
intervalUnit: IntervalUnit;
/** mode === 'custom': Roh-Cron (5 oder 6 Felder) */
customCron: string;
/** mode === 'weeks': alle W Wochen (Phase 1: Cron entspricht W===1; W>1 nur in schedule persistiert) */
weeksInterval: number;
}
export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const;
/** Anzeige MoSo (cron DOW) */
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' },
];
/** Minuten-Optionen für Dropdowns (wie Prototyp + feinere Schritte bis 59) */
export const MINUTE_SELECT_OPTIONS: readonly number[] = (() => {
const base = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
const set = new Set(base);
for (let i = 0; i < 60; i++) if (!set.has(i)) set.add(i);
return [...set].sort((a, b) => a - b);
})();
export function defaultScheduleSpec(): ScheduleSpec {
return {
mode: 'days',
hour: 9,
minute: 0,
weekdays: [1, 2, 3, 4, 5],
monthDay: 1,
monthIndex: 1,
calendarPeriod: 'monthly',
intervalValue: 1,
intervalUnit: 'minutes',
customCron: '0 9 * * 1-5',
weeksInterval: 1,
};
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/** Normalisiert Legacy-Modi für Planner-UI (ohne Cron neu zu bauen). */
export function normalizeSpecForPlanner(spec: ScheduleSpec): ScheduleSpec {
const s = { ...spec };
switch (s.mode) {
case 'daily':
return { ...s, mode: 'days', intervalValue: 1 };
case 'weekdays':
return {
...s,
mode: 'weeks',
weeksInterval: 1,
weekdays: [1, 2, 3, 4, 5],
};
case 'weekly':
return { ...s, mode: 'weeks', weeksInterval: s.weeksInterval || 1 };
case 'calendar':
if (s.calendarPeriod === 'yearly') {
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'calendar' }),
};
}
return {
...s,
mode: 'months',
intervalValue: 1,
monthDay: clamp(s.monthDay, 1, 28),
};
case 'interval': {
const u = s.intervalUnit;
if (u === 'minutes')
return { ...s, mode: 'minutes', intervalValue: Math.max(1, s.intervalValue) };
if (u === 'hours')
return {
...s,
mode: 'hours',
intervalValue: Math.max(1, s.intervalValue),
minute: 0,
};
if (u === 'days')
return {
...s,
mode: 'days',
intervalValue: Math.max(1, s.intervalValue),
hour: 0,
minute: 0,
};
if (u === 'seconds')
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
return {
...defaultScheduleSpec(),
mode: 'custom',
customCron: buildCronFromSpec({ ...s, mode: 'interval' }),
};
}
default:
return s;
}
}
/** 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 'minutes': {
const mm = clamp(Math.floor(spec.intervalValue), 1, 59);
return `*/${mm} * * * *`;
}
case 'hours': {
const hh = clamp(Math.floor(spec.intervalValue), 1, 23);
return `${m} */${hh} * * *`;
}
case 'days': {
const d = Math.max(1, Math.floor(spec.intervalValue));
if (d <= 1) return `${m} ${h} * * *`;
const step = clamp(d, 2, 31);
return `${m} ${h} */${step} * *`;
}
case 'weeks':
case 'weekly': {
const days = [...new Set(spec.weekdays)]
.filter((x) => x >= 0 && x <= 6)
.sort((a, b) => {
const order = (x: number) => (x === 0 ? 7 : x);
return order(a) - order(b);
});
// Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck
if (days.length === 0) return `${m} ${h} * * 1`;
return `${m} ${h} * * ${days.join(',')}`;
}
case 'months': {
const dom = clamp(Math.floor(spec.monthDay), 1, 28);
const monIv = Math.max(1, Math.floor(spec.intervalValue));
if (monIv <= 1) return `${m} ${h} ${dom} * *`;
const step = clamp(monIv, 2, 12);
return `${m} ${h} ${dom} */${step} *`;
}
case 'custom': {
const c = (spec.customCron || '').trim();
if (!c) return '0 9 * * *';
return c;
}
case 'daily':
return `${m} ${h} * * *`;
case 'weekdays':
return `${m} ${h} * * 1-5`;
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 sec = clamp(v, 1, 59);
return `*/${sec} * * * * *`;
}
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 `${m} ${h} * * *`;
const d = clamp(v, 2, 31);
return `${m} ${h} */${d} * *`;
}
case 'years':
default:
return `0 0 1 1 *`;
}
}
default:
return `${m} ${h} * * *`;
}
}
/** Validiert 5- oder 6-Feld-Cron (Whitespace-getrennt). */
export function isValidCronFieldCount(cron: string): boolean {
const n = cron.trim().split(/\s+/).length;
return n === 5 || n === 6;
}
/** 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 trimmed = cron.trim();
const p = trimmed.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,
};
}
}
const base = { ...defaultScheduleSpec(), mode: 'custom' as const, customCron: trimmed };
return base;
}
if (p.length < 5) return null;
let [minS, hourS, domS, monthS, dowS] = p;
if (minS.startsWith('*/') && hourS === '*' && domS === '*') {
const iv = parseInt(minS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'minutes',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
const minNum = parseInt(minS, 10);
const hourNum = parseInt(hourS, 10);
if (
!minS.startsWith('*/') &&
!Number.isNaN(minNum) &&
hourS.startsWith('*/') &&
domS === '*' &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: minNum,
hour: 0,
};
}
}
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
const iv = parseInt(hourS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'hours',
intervalValue: iv,
minute: 0,
hour: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
domS.startsWith('*/') &&
monthS === '*' &&
(dowS === '*' || dowS === '?')
) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: hourNum,
minute: minNum,
};
}
}
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
const iv = parseInt(domS.slice(2), 10);
if (!Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'days',
intervalValue: iv,
hour: 0,
minute: 0,
};
}
}
if (
!Number.isNaN(minNum) &&
!Number.isNaN(hourNum) &&
!domS.startsWith('*/') &&
monthS.startsWith('*/') &&
(dowS === '*' || dowS === '?')
) {
const dom = parseInt(domS, 10);
const iv = parseInt(monthS.slice(2), 10);
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && !Number.isNaN(iv)) {
return {
...defaultScheduleSpec(),
mode: 'months',
intervalValue: iv,
monthDay: dom,
hour: hourNum,
minute: minNum,
};
}
}
const minute = minNum;
const hour = hourNum;
if (Number.isNaN(minute) || Number.isNaN(hour)) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
if (domS === '*' && dowS === '*') {
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
}
if (domS === '*' && dowS === '1-5') {
return {
...defaultScheduleSpec(),
mode: 'weekdays',
hour,
minute,
weekdays: [1, 2, 3, 4, 5],
};
}
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,
weeksInterval: 1,
};
}
}
const dom = parseInt(domS, 10);
const month = monthS === '*' ? NaN : parseInt(monthS, 10);
if (
(domS.includes(',') || domS.includes('-') || domS.includes('/')) &&
!domS.startsWith('*/')
) {
return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
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 { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed };
}
const VALID_MODES: ScheduleMode[] = [
'minutes',
'hours',
'days',
'weeks',
'months',
'custom',
'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';
}
const spec: ScheduleSpec = {
mode: mode as ScheduleMode,
hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour,
minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute,
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),
customCron: typeof o.customCron === 'string' ? o.customCron : base.customCron,
weeksInterval: Math.max(1, Number(o.weeksInterval) || base.weeksInterval),
};
return normalizeSpecForPlanner(spec);
}
}
const cron = typeof params.cron === 'string' ? params.cron : '';
const parsed = parseCronToSpec(cron);
return normalizeSpecForPlanner(parsed ?? defaultScheduleSpec());
}
/** Serialisiert Spec für parameters.schedule (persistiert). */
export function scheduleSpecToPersistentJson(spec: ScheduleSpec): Record<string, unknown> {
return {
mode: spec.mode,
hour: spec.hour,
minute: spec.minute,
weekdays: spec.weekdays,
monthDay: spec.monthDay,
monthIndex: spec.monthIndex,
calendarPeriod: spec.calendarPeriod,
intervalValue: spec.intervalValue,
intervalUnit: spec.intervalUnit,
customCron: spec.customCron,
weeksInterval: spec.weeksInterval,
};
}