531 lines
15 KiB
TypeScript
531 lines
15 KiB
TypeScript
/**
|
||
* 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<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,
|
||
};
|
||
}
|