/** * Quartz 형식 Cron 표현식의 다음 실행 시간을 계산한다. * 형식: 초 분 시 일 월 요일 */ export function getNextExecutions(cron: string, count: number): Date[] { const parts = cron.trim().split(/\s+/); if (parts.length < 6) return []; const [secField, minField, hourField, dayField, monthField, dowField] = parts; if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) { return []; } const seconds = parseField(secField, 0, 59); const minutes = parseField(minField, 0, 59); const hours = parseField(hourField, 0, 23); const daysOfMonth = parseField(dayField, 1, 31); const months = parseField(monthField, 1, 12); const daysOfWeek = parseDowField(dowField); if (!seconds || !minutes || !hours || !months) return []; const results: Date[] = []; const now = new Date(); const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1); cursor.setMilliseconds(0); const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); while (results.length < count && cursor.getTime() <= limit.getTime()) { const month = cursor.getMonth() + 1; if (!months.includes(month)) { cursor.setMonth(cursor.getMonth() + 1, 1); cursor.setHours(0, 0, 0, 0); continue; } const day = cursor.getDate(); const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true; const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true; const needDayCheck = dayField !== '?' && dowField !== '?'; const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches; if (!dayOk) { cursor.setDate(cursor.getDate() + 1); cursor.setHours(0, 0, 0, 0); continue; } const hour = cursor.getHours(); if (!hours.includes(hour)) { cursor.setHours(cursor.getHours() + 1, 0, 0, 0); continue; } const minute = cursor.getMinutes(); if (!minutes.includes(minute)) { cursor.setMinutes(cursor.getMinutes() + 1, 0, 0); continue; } const second = cursor.getSeconds(); if (!seconds.includes(second)) { cursor.setSeconds(cursor.getSeconds() + 1, 0); continue; } results.push(new Date(cursor)); cursor.setSeconds(cursor.getSeconds() + 1); } return results; } function hasUnsupportedToken(field: string): boolean { return /[LW#]/.test(field); } function parseField(field: string, min: number, max: number): number[] | null { if (field === '?') return null; if (field === '*') return range(min, max); const values = new Set(); for (const part of field.split(',')) { const stepMatch = part.match(/^(.+)\/(\d+)$/); if (stepMatch) { const [, base, stepStr] = stepMatch; const step = parseInt(stepStr, 10); let start = min; let end = max; if (base === '*') { start = min; } else if (base.includes('-')) { const [lo, hi] = base.split('-').map(Number); start = lo; end = hi; } else { start = parseInt(base, 10); } for (let v = start; v <= end; v += step) { if (v >= min && v <= max) values.add(v); } continue; } const rangeMatch = part.match(/^(\d+)-(\d+)$/); if (rangeMatch) { const lo = parseInt(rangeMatch[1], 10); const hi = parseInt(rangeMatch[2], 10); for (let v = lo; v <= hi; v++) { if (v >= min && v <= max) values.add(v); } continue; } const num = parseInt(part, 10); if (!isNaN(num) && num >= min && num <= max) { values.add(num); } } return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max); } function parseDowField(field: string): number[] | null { if (field === '?' || field === '*') return null; const dayMap: Record = { SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6', }; let normalized = field.toUpperCase(); for (const [name, num] of Object.entries(dayMap)) { normalized = normalized.replace(new RegExp(name, 'g'), num); } // Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT const parsed = parseField(normalized, 1, 7); if (!parsed) return null; return parsed.map((v) => v - 1); } function range(min: number, max: number): number[] { const result: number[] = []; for (let i = min; i <= max; i++) result.push(i); return result; }