mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
284 lines
9.2 KiB
TypeScript
284 lines
9.2 KiB
TypeScript
/**
|
|
* Component: Cron Utilities
|
|
* Documentation: documentation/backend/services/scheduler.md
|
|
*/
|
|
|
|
export interface SchedulePreset {
|
|
label: string;
|
|
cron: string;
|
|
description: string;
|
|
}
|
|
|
|
export const SCHEDULE_PRESETS: SchedulePreset[] = [
|
|
{ label: 'Every 15 minutes', cron: '*/15 * * * *', description: 'Runs 4 times per hour' },
|
|
{ label: 'Every 30 minutes', cron: '*/30 * * * *', description: 'Runs twice per hour' },
|
|
{ label: 'Every hour', cron: '0 * * * *', description: 'Runs at the start of every hour' },
|
|
{ label: 'Every 2 hours', cron: '0 */2 * * *', description: 'Runs 12 times per day' },
|
|
{ label: 'Every 3 hours', cron: '0 */3 * * *', description: 'Runs 8 times per day' },
|
|
{ label: 'Every 6 hours', cron: '0 */6 * * *', description: 'Runs 4 times per day' },
|
|
{ label: 'Every 12 hours', cron: '0 */12 * * *', description: 'Runs twice per day' },
|
|
{ label: 'Daily at midnight', cron: '0 0 * * *', description: 'Runs once per day at 12:00 AM' },
|
|
{ label: 'Daily at noon', cron: '0 12 * * *', description: 'Runs once per day at 12:00 PM' },
|
|
{ label: 'Daily at 6 AM', cron: '0 6 * * *', description: 'Runs once per day at 6:00 AM' },
|
|
{ label: 'Weekly (Sunday midnight)', cron: '0 0 * * 0', description: 'Runs once per week' },
|
|
{ label: 'Monthly (1st at midnight)', cron: '0 0 1 * *', description: 'Runs once per month' },
|
|
];
|
|
|
|
/**
|
|
* Converts a cron expression to a human-readable description
|
|
* @param cron - The cron expression (e.g., "0 *\/6 * * *")
|
|
* @returns Human-readable description (e.g., "Every 6 hours")
|
|
*/
|
|
export function cronToHuman(cron: string): string {
|
|
// Check if it matches a preset
|
|
const preset = SCHEDULE_PRESETS.find(p => p.cron === cron);
|
|
if (preset) {
|
|
return preset.label;
|
|
}
|
|
|
|
// Parse the cron expression
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length < 5) {
|
|
return cron; // Invalid cron, return as-is
|
|
}
|
|
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
// Minutes pattern
|
|
if (minute.startsWith('*/')) {
|
|
const interval = parseInt(minute.substring(2), 10);
|
|
return `Every ${interval} minute${interval !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// Hours pattern
|
|
if (hour.startsWith('*/') && minute === '0') {
|
|
const interval = parseInt(hour.substring(2), 10);
|
|
return `Every ${interval} hour${interval !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// Daily pattern
|
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
if (hour === '*') {
|
|
if (minute === '0') {
|
|
return 'Every hour';
|
|
}
|
|
const minInterval = parseInt(minute.replace('*/', ''), 10);
|
|
return `Every ${minInterval} minutes`;
|
|
}
|
|
|
|
const hourNum = parseInt(hour, 10);
|
|
if (!isNaN(hourNum)) {
|
|
const minuteNum = parseInt(minute, 10);
|
|
const time = formatTime(hourNum, minuteNum);
|
|
return `Daily at ${time}`;
|
|
}
|
|
}
|
|
|
|
// Weekly pattern
|
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
|
|
const days = parseDayOfWeek(dayOfWeek);
|
|
const hourNum = parseInt(hour, 10);
|
|
const minuteNum = parseInt(minute, 10);
|
|
const time = formatTime(hourNum, minuteNum);
|
|
return `Weekly on ${days} at ${time}`;
|
|
}
|
|
|
|
// Monthly pattern
|
|
if (month === '*' && dayOfWeek === '*' && dayOfMonth !== '*') {
|
|
const day = parseInt(dayOfMonth, 10);
|
|
const hourNum = parseInt(hour, 10);
|
|
const minuteNum = parseInt(minute, 10);
|
|
const time = formatTime(hourNum, minuteNum);
|
|
return `Monthly on day ${day} at ${time}`;
|
|
}
|
|
|
|
// Fallback: return the cron expression
|
|
return cron;
|
|
}
|
|
|
|
/**
|
|
* Parse day of week number to name
|
|
*/
|
|
function parseDayOfWeek(dayOfWeek: string): string {
|
|
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
const day = parseInt(dayOfWeek, 10);
|
|
if (!isNaN(day) && day >= 0 && day <= 6) {
|
|
return dayNames[day];
|
|
}
|
|
return dayOfWeek;
|
|
}
|
|
|
|
/**
|
|
* Format hour and minute to 12-hour time
|
|
*/
|
|
function formatTime(hour: number, minute: number): string {
|
|
const period = hour >= 12 ? 'PM' : 'AM';
|
|
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
const displayMinute = minute.toString().padStart(2, '0');
|
|
return `${displayHour}:${displayMinute} ${period}`;
|
|
}
|
|
|
|
/**
|
|
* Validates a cron expression
|
|
* @param cron - The cron expression to validate
|
|
* @returns true if valid, false otherwise
|
|
*/
|
|
export function isValidCron(cron: string): boolean {
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
return false;
|
|
}
|
|
|
|
// Basic validation - each part should be valid
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
return (
|
|
isValidCronField(minute, 0, 59) &&
|
|
isValidCronField(hour, 0, 23) &&
|
|
isValidCronField(dayOfMonth, 1, 31) &&
|
|
isValidCronField(month, 1, 12) &&
|
|
isValidCronField(dayOfWeek, 0, 7)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validates a single cron field
|
|
*/
|
|
function isValidCronField(field: string, min: number, max: number): boolean {
|
|
// Asterisk is always valid
|
|
if (field === '*') {
|
|
return true;
|
|
}
|
|
|
|
// Step values (*/n)
|
|
if (field.startsWith('*/')) {
|
|
const step = parseInt(field.substring(2), 10);
|
|
return !isNaN(step) && step > 0 && step <= max;
|
|
}
|
|
|
|
// Range (n-m)
|
|
if (field.includes('-')) {
|
|
const [start, end] = field.split('-').map(s => parseInt(s, 10));
|
|
return !isNaN(start) && !isNaN(end) && start >= min && end <= max && start < end;
|
|
}
|
|
|
|
// List (n,m,o)
|
|
if (field.includes(',')) {
|
|
const values = field.split(',').map(s => parseInt(s, 10));
|
|
return values.every(v => !isNaN(v) && v >= min && v <= max);
|
|
}
|
|
|
|
// Single value
|
|
const value = parseInt(field, 10);
|
|
return !isNaN(value) && value >= min && value <= max;
|
|
}
|
|
|
|
/**
|
|
* Custom schedule builder
|
|
*/
|
|
export interface CustomSchedule {
|
|
type: 'minutes' | 'hours' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
|
interval?: number; // For minutes/hours
|
|
time?: { hour: number; minute: number }; // For daily/weekly/monthly
|
|
dayOfWeek?: number; // For weekly (0-6)
|
|
dayOfMonth?: number; // For monthly (1-31)
|
|
customCron?: string; // For custom
|
|
}
|
|
|
|
/**
|
|
* Converts custom schedule to cron expression
|
|
*/
|
|
export function customScheduleToCron(schedule: CustomSchedule): string {
|
|
switch (schedule.type) {
|
|
case 'minutes':
|
|
return `*/${schedule.interval || 15} * * * *`;
|
|
|
|
case 'hours':
|
|
const hourInterval = schedule.interval || 1;
|
|
// If interval is 24 or more hours, convert to daily at midnight
|
|
if (hourInterval >= 24) {
|
|
return `0 0 * * *`; // Daily at midnight
|
|
}
|
|
return `0 */${hourInterval} * * *`;
|
|
|
|
case 'daily':
|
|
const dailyHour = schedule.time?.hour || 0;
|
|
const dailyMinute = schedule.time?.minute || 0;
|
|
return `${dailyMinute} ${dailyHour} * * *`;
|
|
|
|
case 'weekly':
|
|
const weeklyHour = schedule.time?.hour || 0;
|
|
const weeklyMinute = schedule.time?.minute || 0;
|
|
const weeklyDay = schedule.dayOfWeek || 0;
|
|
return `${weeklyMinute} ${weeklyHour} * * ${weeklyDay}`;
|
|
|
|
case 'monthly':
|
|
const monthlyHour = schedule.time?.hour || 0;
|
|
const monthlyMinute = schedule.time?.minute || 0;
|
|
const monthlyDay = schedule.dayOfMonth || 1;
|
|
return `${monthlyMinute} ${monthlyHour} ${monthlyDay} * *`;
|
|
|
|
case 'custom':
|
|
return schedule.customCron || '0 * * * *';
|
|
|
|
default:
|
|
return '0 * * * *';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempts to parse a cron expression into a custom schedule
|
|
*/
|
|
export function cronToCustomSchedule(cron: string): CustomSchedule {
|
|
const parts = cron.trim().split(/\s+/);
|
|
if (parts.length !== 5) {
|
|
return { type: 'custom', customCron: cron };
|
|
}
|
|
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
|
|
// Minutes pattern
|
|
if (minute.startsWith('*/') && hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
const interval = parseInt(minute.substring(2), 10);
|
|
return { type: 'minutes', interval };
|
|
}
|
|
|
|
// Hours pattern
|
|
if (hour.startsWith('*/') && minute === '0' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
const interval = parseInt(hour.substring(2), 10);
|
|
return { type: 'hours', interval };
|
|
}
|
|
|
|
// Daily pattern
|
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*' && !hour.includes('*') && !minute.includes('*')) {
|
|
const hourNum = parseInt(hour, 10);
|
|
const minuteNum = parseInt(minute, 10);
|
|
if (!isNaN(hourNum) && !isNaN(minuteNum)) {
|
|
return { type: 'daily', time: { hour: hourNum, minute: minuteNum } };
|
|
}
|
|
}
|
|
|
|
// Weekly pattern
|
|
if (dayOfMonth === '*' && month === '*' && !dayOfWeek.includes('*') && !hour.includes('*') && !minute.includes('*')) {
|
|
const hourNum = parseInt(hour, 10);
|
|
const minuteNum = parseInt(minute, 10);
|
|
const dayNum = parseInt(dayOfWeek, 10);
|
|
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
|
|
return { type: 'weekly', time: { hour: hourNum, minute: minuteNum }, dayOfWeek: dayNum };
|
|
}
|
|
}
|
|
|
|
// Monthly pattern
|
|
if (month === '*' && dayOfWeek === '*' && !dayOfMonth.includes('*') && !hour.includes('*') && !minute.includes('*')) {
|
|
const hourNum = parseInt(hour, 10);
|
|
const minuteNum = parseInt(minute, 10);
|
|
const dayNum = parseInt(dayOfMonth, 10);
|
|
if (!isNaN(hourNum) && !isNaN(minuteNum) && !isNaN(dayNum)) {
|
|
return { type: 'monthly', time: { hour: hourNum, minute: minuteNum }, dayOfMonth: dayNum };
|
|
}
|
|
}
|
|
|
|
// Fallback to custom
|
|
return { type: 'custom', customCron: cron };
|
|
}
|