/** * 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; /** 0–6, cron DOW; nur bei mode === 'weekly' */ weekdays: number[]; /** Monatlich: Tag 1–31; Jährlich: Tag im gewählten Monat */ monthDay: number; /** 1–12, 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 Mo–So (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): ScheduleSpec { const raw = params.schedule; if (raw && typeof raw === 'object' && !Array.isArray(raw)) { const o = raw as Record; 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(); }