296 lines
8.5 KiB
TypeScript
296 lines
8.5 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)
|
||
*/
|
||
|
||
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<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();
|
||
}
|