/** * 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; /** 0–6 cron DOW; für weeks / weekly / weekdays */ weekdays: number[]; /** Tag des Monats 1–31 (Planner months: 1–28 empfohlen) */ monthDay: number; /** 1–12, 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 Mo–So (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): 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'; } 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 { 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, }; }