diff --git a/src/islands/dev/CronExplainer.tsx b/src/islands/dev/CronExplainer.tsx new file mode 100644 index 0000000..a753022 --- /dev/null +++ b/src/islands/dev/CronExplainer.tsx @@ -0,0 +1,155 @@ +import { useState, useMemo } from 'react'; +import { CalendarClock } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { CopyButton } from '@/components/ui/CopyButton'; +import { Alert } from '@/components/ui/Alert'; +import { + parseCron, explainCron, nextRuns, + FIELD_LABELS, CRON_PRESETS, +} from '@/tools/dev/cron.lib'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { + exprLabel: 'Cron expression', + description: 'Description', + nextRuns: 'Next 10 run times', + presets: 'Presets', + invalidExpr: 'Invalid expression', + every: 'Every minute', + noRuns: 'No scheduled runs found in the next 4 years.', + fieldHints: 'Field hints', + }, + id: { + exprLabel: 'Ekspresi cron', + description: 'Deskripsi', + nextRuns: '10 waktu eksekusi berikutnya', + presets: 'Preset', + invalidExpr: 'Ekspresi tidak valid', + every: 'Setiap menit', + noRuns: 'Tidak ada jadwal yang ditemukan dalam 4 tahun ke depan.', + fieldHints: 'Panduan field', + }, +}; + +function formatDate(d: Date): string { + return d.toLocaleString(undefined, { + weekday: 'short', year: 'numeric', month: 'short', + day: '2-digit', hour: '2-digit', minute: '2-digit', + }); +} + +export default function CronExplainer({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [expr, setExpr] = useState('* * * * *'); + + const parsed = useMemo(() => parseCron(expr), [expr]); + const explanation = useMemo(() => { + if (!parsed.ok) return null; + return explainCron(parsed.cron); + }, [parsed]); + const runs = useMemo(() => { + if (!parsed.ok) return []; + return nextRuns(parsed.cron, new Date(), 10); + }, [parsed]); + + const parts = expr.trim().split(/\s+/); + const fieldParts = parts.length === 5 ? parts : ['*', '*', '*', '*', '*']; + + return ( +
+ {/* Main input */} +
+ +
+ setExpr(e.target.value)} + spellCheck={false} + className="flex-1 border-2 border-border bg-background px-3 py-2 font-mono text-xl outline-none focus:shadow-brutal-sm" + placeholder="* * * * *" + aria-label={t.exprLabel} + /> + +
+ + {/* Field labels */} +
+ {FIELD_LABELS.map((f, i) => ( +
+
+ {fieldParts[i] ?? '*'} +
+
{f.label}
+
{f.hint}
+
+ ))} +
+
+ + {/* Error */} + {!parsed.ok && ( + {t.invalidExpr}: {parsed.error} + )} + + {/* Description */} + {explanation && ( +
+
{t.description}
+

{explanation}

+
+ )} + + {/* Presets */} +
+
{t.presets}
+
+ {CRON_PRESETS.map(p => ( + + ))} +
+
+ + {/* Next runs */} + {parsed.ok && ( +
+
+ + {t.nextRuns} +
+ {runs.length === 0 ? ( +

{t.noRuns}

+ ) : ( +
    + {runs.map((d, i) => ( +
  1. + {i + 1} + {formatDate(d)} +
  2. + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index fd99802..bb627d4 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -58,6 +58,23 @@ const en: Record = { { q: 'Are duplicates removed from the result?', a: 'Yes — every operation returns a de-duplicated list, preserving the first occurrence of each line and its original text.' }, ], }, + 'cron-expression': { + title: 'Free Cron Expression Editor — Cron Syntax Explainer Online', + description: 'Parse and explain cron expressions in plain English, see the next 10 scheduled run times, and pick from common presets. 100% client-side; nothing is uploaded.', + intro: 'This free cron expression editor explains any standard cron schedule in plain English and shows the next 10 run times — so you can verify your schedule at a glance. Type or paste a 5-field cron expression (minute hour dom month weekday), pick a preset, and see exactly when your job will fire next. Everything runs in your browser; nothing is sent to a server.', + howTo: [ + 'Type a 5-field cron expression in the box (e.g. 0 9 * * 1-5 for weekdays at 09:00).', + 'The human-readable description updates instantly as you type.', + 'Check the "Next 10 run times" list to confirm the schedule looks right.', + 'Use the preset buttons for common schedules like every 15 minutes, daily, or monthly.', + ], + faqs: [ + { q: 'What is the cron field order?', a: 'Standard unix cron uses 5 fields left-to-right: minute (0–59), hour (0–23), day of month (1–31), month (1–12), and day of week (0–7, where both 0 and 7 mean Sunday).' }, + { q: 'What special characters can I use?', a: 'Star (*) matches every value; */n means every n units; a-b is a range; a,b,c is a list; and a-b/n is a range with step. For example */15 fires every 15 minutes and 1-5 means Monday through Friday.' }, + { q: 'How does the DOM/DOW interaction work?', a: 'Standard unix cron: if both the day-of-month and day-of-week fields are restricted (not *), a time fires when EITHER condition matches — the two are combined with OR, not AND.' }, + { q: 'Is my cron expression sent to a server?', a: 'No. Parsing, explaining and computing run times all happen entirely in your browser with JavaScript. Nothing is ever uploaded.' }, + ], + }, 'docx-to-pdf': { title: 'Free DOCX to PDF Converter — Word to PDF Online', description: 'Convert Word (.docx) documents to PDF right in your browser — page-accurate, with your layout, tables and images. 100% private; nothing is uploaded.', @@ -1495,6 +1512,23 @@ const id: Record = { { q: 'Apakah duplikat dihapus dari hasil?', a: 'Ya — setiap operasi mengembalikan daftar tanpa duplikat, mempertahankan kemunculan pertama setiap baris beserta teks aslinya.' }, ], }, + 'cron-expression': { + title: 'Tool Ekspresi Cron Gratis — Penjelas Sintaks Cron Online', + description: 'Urai dan jelaskan ekspresi cron dalam bahasa Indonesia yang mudah dipahami, lihat 10 waktu eksekusi berikutnya, dan pilih dari preset umum. 100% di sisi klien; tidak ada yang diunggah.', + intro: 'Tool ekspresi cron gratis ini menjelaskan jadwal cron dalam bahasa yang mudah dipahami dan menampilkan 10 waktu eksekusi berikutnya — sehingga Anda bisa memverifikasi jadwal sekilas. Ketik atau tempel ekspresi cron 5-field (menit jam hari-bulan bulan hari-minggu), pilih preset, dan lihat kapan pekerjaan Anda akan berjalan selanjutnya. Semuanya berjalan di browser; tidak ada yang dikirim ke server.', + howTo: [ + 'Ketik ekspresi cron 5-field di kotak (misalnya 0 9 * * 1-5 untuk hari kerja pukul 09:00).', + 'Deskripsi dalam bahasa yang mudah dipahami diperbarui secara instan saat Anda mengetik.', + 'Periksa daftar "10 waktu eksekusi berikutnya" untuk memastikan jadwal sudah benar.', + 'Gunakan tombol preset untuk jadwal umum seperti setiap 15 menit, harian, atau bulanan.', + ], + faqs: [ + { q: 'Apa urutan field dalam cron?', a: 'Cron unix standar menggunakan 5 field dari kiri ke kanan: menit (0–59), jam (0–23), hari-dalam-bulan (1–31), bulan (1–12), dan hari-dalam-minggu (0–7, di mana 0 dan 7 keduanya berarti Minggu).' }, + { q: 'Karakter khusus apa yang bisa saya gunakan?', a: 'Tanda bintang (*) cocok dengan setiap nilai; */n berarti setiap n unit; a-b adalah rentang; a,b,c adalah daftar; dan a-b/n adalah rentang dengan langkah. Misalnya */15 berjalan setiap 15 menit dan 1-5 berarti Senin hingga Jumat.' }, + { q: 'Bagaimana interaksi DOM/DOW bekerja?', a: 'Cron unix standar: jika field hari-dalam-bulan dan hari-dalam-minggu keduanya dibatasi (bukan *), jadwal berjalan ketika SALAH SATU kondisi terpenuhi — keduanya digabung dengan OR, bukan AND.' }, + { q: 'Apakah ekspresi cron saya dikirim ke server?', a: 'Tidak. Penguraian, penjelasan, dan penghitungan waktu eksekusi semuanya terjadi sepenuhnya di browser Anda dengan JavaScript. Tidak ada yang pernah diunggah.' }, + ], + }, 'docx-to-pdf': { title: 'Konverter DOCX ke PDF Gratis — Word ke PDF Online', description: 'Konversi dokumen Word (.docx) ke PDF langsung di browser Anda — akurat per halaman, dengan tata letak, tabel, dan gambar. 100% privat; tidak ada yang diunggah.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 18585e6..588f06d 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -1,4 +1,4 @@ -import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput } from 'lucide-react'; +import { Hash, Braces, Binary, Link, KeyRound, Fingerprint, KeySquare, FileDiff, Table, FileText, QrCode, ScanLine, Clock, Calculator, Palette, FilePlus2, Scissors, RotateCw, FileImage, FileX, Stamp, Image, Replace, Minimize2, Maximize2, Eraser, Archive, Lock, Unlock, Crop, Droplet, PenTool, Combine, ShieldCheck, FileCode, FileCode2, FileCog, FileArchive, FolderArchive, Sparkles, ScanFace, Scaling, Aperture, Wand2, PenLine, Shapes, Film, FileVideo, Music, AudioLines, MonitorPlay, Camera, Code2, Database, Keyboard, Contrast, Eye, ScanText, Receipt, Webcam, Mic, Send, Video, Wrench, Compass, Map, Waypoints, ImageDown, ScrollText, Ghost, FileSpreadsheet, BookOpen, FileType2, FileDown, GitCompare, FileOutput, CalendarClock } from 'lucide-react'; import type { ToolDef } from '@/types/tool'; export const tools: ToolDef[] = [ @@ -179,6 +179,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/dev/SqlFormat'), status: 'beta' }, + { + id: 'cron-expression', + name: 'Cron Expression', + category: 'Dev', + route: '/tools/cron-expression', + keywords: ['cron', 'crontab', 'schedule', 'scheduler', 'expression', 'timer', 'interval', 'recurring', 'task', 'job', 'unix', 'linux', 'automation'], + icon: CalendarClock, + summary: 'Parse and explain cron expressions with next scheduled run times', + load: () => import('@/islands/dev/CronExplainer'), + status: 'beta' + }, { id: 'docx-viewer', name: 'Word (DOCX) Viewer', diff --git a/src/tools/dev/cron.lib.test.ts b/src/tools/dev/cron.lib.test.ts new file mode 100644 index 0000000..0cf7e09 --- /dev/null +++ b/src/tools/dev/cron.lib.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { + parseField, parseCron, explainCron, matchesCron, nextRuns, + FIELD_META, type ParsedCron, +} from './cron.lib'; + +// ─── parseField ────────────────────────────────────────────────────────────── + +describe('parseField', () => { + it('* resolves to all values in range', () => { + const f = parseField('*', FIELD_META.minute); + if (typeof f === 'string') throw f; + expect(f.isAll).toBe(true); + expect(f.values.has(0)).toBe(true); + expect(f.values.has(59)).toBe(true); + expect(f.values.size).toBe(60); + }); + + it('single value', () => { + const f = parseField('30', FIELD_META.minute); + if (typeof f === 'string') throw f; + expect(f.values).toEqual(new Set([30])); + expect(f.isAll).toBe(false); + }); + + it('range a-b', () => { + const f = parseField('1-5', FIELD_META.dow); + if (typeof f === 'string') throw f; + expect([...f.values].sort((a,b)=>a-b)).toEqual([1,2,3,4,5]); + expect(f.range).toEqual([1, 5]); + }); + + it('step */n', () => { + const f = parseField('*/15', FIELD_META.minute); + if (typeof f === 'string') throw f; + expect([...f.values].sort((a,b)=>a-b)).toEqual([0,15,30,45]); + expect(f.step).toBe(15); + }); + + it('range with step a-b/n', () => { + const f = parseField('1-6/2', FIELD_META.month); + if (typeof f === 'string') throw f; + expect([...f.values].sort((a,b)=>a-b)).toEqual([1,3,5]); + }); + + it('list a,b,c', () => { + const f = parseField('0,6', FIELD_META.dow); + if (typeof f === 'string') throw f; + expect(f.values.has(0)).toBe(true); + expect(f.values.has(6)).toBe(true); + expect(f.list).toEqual([0, 6]); + }); + + it('dow 7 is normalised to 0 (Sunday)', () => { + const f = parseField('7', FIELD_META.dow); + if (typeof f === 'string') throw f; + expect(f.values.has(0)).toBe(true); + expect(f.values.has(7)).toBe(false); + }); + + it('returns error string for out-of-range', () => { + expect(typeof parseField('60', FIELD_META.minute)).toBe('string'); + expect(typeof parseField('24', FIELD_META.hour)).toBe('string'); + expect(typeof parseField('0', FIELD_META.dom)).toBe('string'); + expect(typeof parseField('13', FIELD_META.month)).toBe('string'); + }); + + it('returns error for invalid step', () => { + expect(typeof parseField('*/0', FIELD_META.minute)).toBe('string'); + expect(typeof parseField('*/abc', FIELD_META.minute)).toBe('string'); + }); +}); + +// ─── parseCron ─────────────────────────────────────────────────────────────── + +describe('parseCron', () => { + it('parses a clean 5-field expression', () => { + const r = parseCron('0 9 * * 1-5'); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.cron.minute.values.has(0)).toBe(true); + expect(r.cron.hour.values.has(9)).toBe(true); + expect(r.cron.dom.isAll).toBe(true); + expect(r.cron.dow.values.has(1)).toBe(true); + expect(r.cron.dow.values.has(5)).toBe(true); + }); + + it('rejects fewer than 5 fields', () => { + const r = parseCron('* * *'); + expect(r.ok).toBe(false); + }); + + it('rejects more than 5 fields', () => { + const r = parseCron('* * * * * *'); + expect(r.ok).toBe(false); + }); + + it('propagates per-field errors with field name', () => { + const r = parseCron('60 * * * *'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/minute/); + }); +}); + +// ─── explainCron ───────────────────────────────────────────────────────────── + +function explain(expr: string): string { + const r = parseCron(expr); + if (!r.ok) throw new Error(r.error); + return explainCron(r.cron); +} + +describe('explainCron', () => { + it.each([ + ['* * * * *', 'Every minute'], + ['0 * * * *', 'At the top of every hour'], + ['30 * * * *', 'At minute 30 of every hour'], + ['0 0 * * *', 'At 00:00'], + ['0 9 * * *', 'At 09:00'], + ['0 12 * * *', 'At 12:00'], + ['*/15 * * * *', 'Every 15 minutes'], + ['*/30 * * * *', 'Every 30 minutes'], + ['0 */6 * * *', 'Every 6 hours'], + ['0 */2 * * *', 'Every 2 hours'], + ['0 9 * * 1-5', 'At 09:00, on weekdays'], + ['0 10 * * 0,6', 'At 10:00, on weekends'], + ['0 9 * * 1', 'At 09:00, on Monday'], + ['0 9 * * 0', 'At 09:00, on Sunday'], + ['0 0 1 * *', 'At 00:00, on the 1st of every month'], + ['0 0 1,15 * *', 'At 00:00, on the 1st and 15th of every month'], + ['0 0 1 1 *', 'At 00:00, on the 1st of every month, in January'], + ['0 0 * * 0', 'At 00:00, on Sunday'], + ])('%s → %s', (expr, expected) => { + expect(explain(expr)).toBe(expected); + }); +}); + +// ─── matchesCron ───────────────────────────────────────────────────────────── + +describe('matchesCron', () => { + function parsed(expr: string): ParsedCron { + const r = parseCron(expr); + if (!r.ok) throw new Error(r.error); + return r.cron; + } + function date(y: number, mo: number, d: number, h: number, m: number) { + return new Date(y, mo - 1, d, h, m, 0, 0); + } + + it('* * * * * matches any time', () => { + expect(matchesCron(parsed('* * * * *'), date(2024, 3, 15, 9, 30))).toBe(true); + }); + + it('0 9 * * 1-5 matches weekday 09:00', () => { + const c = parsed('0 9 * * 1-5'); + expect(matchesCron(c, date(2024, 3, 18, 9, 0))).toBe(true); // Monday + expect(matchesCron(c, date(2024, 3, 17, 9, 0))).toBe(false); // Sunday + expect(matchesCron(c, date(2024, 3, 18, 9, 1))).toBe(false); // wrong minute + }); + + it('0 0 1 * * matches 1st of month midnight', () => { + const c = parsed('0 0 1 * *'); + expect(matchesCron(c, date(2024, 6, 1, 0, 0))).toBe(true); + expect(matchesCron(c, date(2024, 6, 2, 0, 0))).toBe(false); + }); + + it('dom+dow both restricted: OR logic', () => { + // 0 0 1 * 1 → midnight on the 1st OR on Monday + const c = parsed('0 0 1 * 1'); + expect(matchesCron(c, date(2024, 3, 1, 0, 0))).toBe(true); // 1st of March + expect(matchesCron(c, date(2024, 3, 4, 0, 0))).toBe(true); // Monday March 4 + expect(matchesCron(c, date(2024, 3, 5, 0, 0))).toBe(false); // Tuesday not 1st + }); +}); + +// ─── nextRuns ──────────────────────────────────────────────────────────────── + +describe('nextRuns', () => { + it('returns the next N runs', () => { + const r = parseCron('0 9 * * *'); + if (!r.ok) throw r.error; + const from = new Date(2024, 2, 18, 8, 0); // March 18 2024 08:00 + const runs = nextRuns(r.cron, from, 3); + expect(runs).toHaveLength(3); + expect(runs[0].getHours()).toBe(9); + expect(runs[0].getMinutes()).toBe(0); + expect(runs[0].getDate()).toBe(18); + expect(runs[1].getDate()).toBe(19); + expect(runs[2].getDate()).toBe(20); + }); + + it('*/15 produces runs 15 minutes apart', () => { + const r = parseCron('*/15 * * * *'); + if (!r.ok) throw r.error; + const from = new Date(2024, 0, 1, 0, 0); + const runs = nextRuns(r.cron, from, 4); + expect(runs.map(d => d.getMinutes())).toEqual([15, 30, 45, 0]); + }); + + it('returns fewer than n when expression is rare', () => { + // 0 0 29 2 * — Feb 29 midnight (leap day), very rare + const r = parseCron('0 0 29 2 *'); + if (!r.ok) throw r.error; + const from = new Date(2024, 0, 1); + const runs = nextRuns(r.cron, from, 5); + // 2024 is a leap year, 2028 is next → at most 2 within 4 years + expect(runs.length).toBeGreaterThanOrEqual(1); + runs.forEach(d => { expect(d.getMonth()).toBe(1); expect(d.getDate()).toBe(29); }); + }); +}); diff --git a/src/tools/dev/cron.lib.ts b/src/tools/dev/cron.lib.ts new file mode 100644 index 0000000..e894d8c --- /dev/null +++ b/src/tools/dev/cron.lib.ts @@ -0,0 +1,348 @@ +/** + * Cron expression parser, human-readable explainer, and next-run calculator. + * Standard 5-field unix cron: minute hour dom month dow + * All logic is pure and runs entirely client-side. + */ + +const MONTH_NAMES = ['', 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December'] as const; +const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const; +const ORDINAL = ['', '1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', '10th', + '11th', '12th', '13th', '14th', '15th', '16th', '17th', '18th', '19th', '20th', + '21st', '22nd', '23rd', '24th', '25th', '26th', '27th', '28th', '29th', '30th', '31st'] as const; + +export interface FieldMeta { min: number; max: number; names?: readonly string[] } + +export const FIELD_META: Record<'minute' | 'hour' | 'dom' | 'month' | 'dow', FieldMeta> = { + minute: { min: 0, max: 59 }, + hour: { min: 0, max: 23 }, + dom: { min: 1, max: 31 }, + month: { min: 1, max: 12, names: MONTH_NAMES }, + dow: { min: 0, max: 7, names: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] }, +}; + +export interface ParsedField { + raw: string; + /** Resolved set of matching values (dow 7 normalised to 0). */ + values: Set; + /** True when the field matches every valid value (raw is star or step-1 of full range). */ + isAll: boolean; + /** Step value when the field is a step expression, else undefined. */ + step?: number; + /** Range [from, to] when the field is a plain range or range+step. */ + range?: [number, number]; + /** List of literal values when the field is a comma-separated list. */ + list?: number[]; +} + +export interface ParsedCron { + minute: ParsedField; + hour: ParsedField; + dom: ParsedField; + month: ParsedField; + dow: ParsedField; +} + +export type CronParseResult = + | { ok: true; cron: ParsedCron } + | { ok: false; error: string }; + +// ─── Parsing ──────────────────────────────────────────────────────────────── + +function resolveRange(from: number, to: number, step: number, min: number, max: number): number[] { + const out: number[] = []; + for (let v = from; v <= Math.min(to, max); v += step) if (v >= min) out.push(v); + return out; +} + +export function parseField(raw: string, meta: FieldMeta): ParsedField | string { + const { min, max } = meta; + + if (raw === '*') { + const values = new Set(); + for (let v = min; v <= max; v++) values.add(v); + return { raw, values, isAll: true }; + } + + // Step: */n or base/n + if (raw.includes('/')) { + const [basePart, stepPart] = raw.split('/'); + const step = parseInt(stepPart, 10); + if (isNaN(step) || step < 1) return `Invalid step "${stepPart}" in "${raw}"`; + + let from = min, to = max; + if (basePart !== '*') { + if (basePart.includes('-')) { + const [a, b] = basePart.split('-').map(Number); + if (isNaN(a) || isNaN(b)) return `Invalid range "${basePart}" in "${raw}"`; + from = a; to = b; + } else { + from = parseInt(basePart, 10); + if (isNaN(from)) return `Invalid base "${basePart}" in "${raw}"`; + to = max; + } + } + if (from < min || to > max) return `Value out of range [${min}-${max}] in "${raw}"`; + const nums = resolveRange(from, to, step, min, max); + const values = new Set(nums.map(v => (meta === FIELD_META.dow && v === 7) ? 0 : v)); + return { raw, values, isAll: step === 1 && from === min && to === max, step, range: [from, to] }; + } + + // List: a,b,c + if (raw.includes(',')) { + const parts = raw.split(','); + const nums: number[] = []; + for (const p of parts) { + const v = parseInt(p, 10); + if (isNaN(v) || v < min || v > max) return `Value "${p}" out of range [${min}-${max}]`; + nums.push(v); + } + const values = new Set(nums.map(v => (meta === FIELD_META.dow && v === 7) ? 0 : v)); + return { raw, values, isAll: false, list: nums }; + } + + // Range: a-b + if (raw.includes('-')) { + const [a, b] = raw.split('-').map(Number); + if (isNaN(a) || isNaN(b)) return `Invalid range "${raw}"`; + if (a < min || b > max || a > b) return `Range "${raw}" out of bounds [${min}-${max}]`; + const nums = resolveRange(a, b, 1, min, max); + const values = new Set(nums.map(v => (meta === FIELD_META.dow && v === 7) ? 0 : v)); + return { raw, values, isAll: a === min && b === max, range: [a, b] }; + } + + // Single value + const v = parseInt(raw, 10); + if (isNaN(v) || v < min || v > max) return `Value "${raw}" out of range [${min}-${max}]`; + const norm = (meta === FIELD_META.dow && v === 7) ? 0 : v; + return { raw, values: new Set([norm]), isAll: false }; +} + +export function parseCron(expr: string): CronParseResult { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return { ok: false, error: `Expected 5 fields, got ${parts.length}` }; + const [m, h, dom, month, dow] = parts; + const fields: [string, 'minute' | 'hour' | 'dom' | 'month' | 'dow'][] = [ + [m, 'minute'], [h, 'hour'], [dom, 'dom'], [month, 'month'], [dow, 'dow'], + ]; + const parsed: Partial = {}; + for (const [raw, name] of fields) { + const res = parseField(raw, FIELD_META[name]); + if (typeof res === 'string') return { ok: false, error: `${name}: ${res}` }; + (parsed as Record)[name] = res; + } + return { ok: true, cron: parsed as ParsedCron }; +} + +// ─── Explanation ───────────────────────────────────────────────────────────── + +function pad(n: number) { return n.toString().padStart(2, '0'); } + +function listNames(values: number[], names: readonly string[]): string { + const ns = [...values].map(v => names[v]).filter(Boolean); + if (ns.length === 0) return ''; + if (ns.length === 1) return ns[0]; + if (ns.length === 2) return `${ns[0]} and ${ns[1]}`; + return ns.slice(0, -1).join(', ') + ', and ' + ns[ns.length - 1]; +} + +function formatTime(hour: ParsedField, minute: ParsedField): string { + const h = [...hour.values][0]; + const m = [...minute.values][0]; + return `${pad(h)}:${pad(m)}`; +} + +export function explainCron(cron: ParsedCron): string { + const { minute, hour, dom, month, dow } = cron; + + // ── Fully wildcard ────────────────────────────────────────────────────── + if (minute.isAll && hour.isAll && dom.isAll && month.isAll && dow.isAll) { + return 'Every minute'; + } + + // ── Time clause ───────────────────────────────────────────────────────── + let timeClause = ''; + + if (minute.isAll && hour.isAll) { + timeClause = 'every minute'; + } else if (!minute.isAll && hour.isAll) { + // e.g. */15 * or 30 * + if (minute.step !== undefined && minute.values.size > 1) { + timeClause = `every ${minute.step} minute${minute.step === 1 ? '' : 's'}`; + } else if (minute.list) { + const ms = minute.list.map(String).join(', '); + timeClause = `at minute${minute.list.length > 1 ? 's' : ''} ${ms} past every hour`; + } else if (minute.range && minute.values.size > 1) { + timeClause = `at every minute from :${pad(minute.range[0])} to :${pad(minute.range[1])}`; + } else { + const m = [...minute.values][0]; + timeClause = m === 0 ? 'at the top of every hour' : `at minute ${m} of every hour`; + } + } else if (minute.values.size === 1 && hour.values.size === 1) { + // Single time + timeClause = `at ${formatTime(hour, minute)}`; + } else if (minute.values.size === 1 && hour.step !== undefined) { + const m = [...minute.values][0]; + timeClause = `every ${hour.step} hour${hour.step === 1 ? '' : 's'}` + + (m === 0 ? '' : ` at minute ${m}`); + } else if (minute.values.size === 1 && hour.list) { + const m = [...minute.values][0]; + const hs = hour.list.map(h => `${pad(h)}:${pad(m)}`).join(', '); + timeClause = `at ${hs}`; + } else if (minute.values.size === 1 && hour.range) { + const m = [...minute.values][0]; + timeClause = `at minute ${m} of every hour from ${pad(hour.range[0])} to ${pad(hour.range[1])}`; + } else if (minute.step !== undefined) { + // */15 with specific hours + const hs = [...hour.values].map(h => pad(h)).join(', '); + timeClause = `every ${minute.step} minutes during hour${hour.values.size > 1 ? 's' : ''} ${hs}`; + } else { + // Fallback for complex combinations + const ms = [...minute.values].map(String).join(', '); + const hs = [...hour.values].map(String).join(', '); + timeClause = `at minute${minute.values.size > 1 ? 's' : ''} ${ms} of hour${hour.values.size > 1 ? 's' : ''} ${hs}`; + } + + // ── Day clause ────────────────────────────────────────────────────────── + const parts: string[] = [timeClause]; + + const hasDow = !dow.isAll; + const hasDom = !dom.isAll; + + if (hasDow && hasDom) { + // Standard unix: DOM OR DOW when both restricted + const domPart = buildDomClause(dom); + const dowPart = buildDowClause(dow); + parts.push(`if it's ${dowPart} or the ${domPart}`); + } else if (hasDow) { + parts.push(buildDowClause(dow)); + } else if (hasDom) { + parts.push(buildDomClause(dom)); + } + + if (!month.isAll) { + if (month.values.size === 1) { + parts.push(`in ${MONTH_NAMES[[...month.values][0]]}`); + } else if (month.range && !month.list) { + parts.push(`from ${MONTH_NAMES[month.range[0]]} through ${MONTH_NAMES[month.range[1]]}`); + } else { + parts.push(`in ${listNames([...month.values], MONTH_NAMES)}`); + } + } + + if (!hasDow && !hasDom && month.isAll) { + // No day restriction — add "every day" only when time is not already "every minute" + if (!minute.isAll || !hour.isAll) { + // time clause already implies daily repetition; don't add redundant suffix + } + } + + return capitalize(parts.join(', ')); +} + +function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1); } + +function buildDomClause(dom: ParsedField): string { + if (dom.list) { + const ords = dom.list.map(d => ORDINAL[d]).join(' and '); + return `on the ${ords} of every month`; + } + if (dom.range) { + return `on days ${dom.range[0]}–${dom.range[1]} of every month`; + } + const d = [...dom.values][0]; + return `on the ${ORDINAL[d]} of every month`; +} + +function buildDowClause(dow: ParsedField): string { + if (dow.values.size === 7) return 'every day'; + + // Detect weekdays / weekends + const vals = [...dow.values].sort((a, b) => a - b); + const weekdays = [1, 2, 3, 4, 5]; + const weekends = [0, 6]; + if (weekdays.every(d => vals.includes(d)) && vals.length === 5) return 'on weekdays'; + if (weekends.every(d => vals.includes(d)) && vals.length === 2) return 'on weekends'; + + if (dow.range) { + return `on every day from ${DOW_NAMES[dow.range[0]]} through ${DOW_NAMES[Math.min(dow.range[1], 6)]}`; + } + if (dow.list || vals.length > 1) { + return `on ${listNames(vals, DOW_NAMES)}`; + } + return `on ${DOW_NAMES[vals[0]]}`; +} + +// ─── Matching & next runs ──────────────────────────────────────────────────── + +export function matchesCron(cron: ParsedCron, date: Date): boolean { + const min = date.getMinutes(); + const hour = date.getHours(); + const dom = date.getDate(); + const month = date.getMonth() + 1; + const dow = date.getDay(); + + if (!cron.minute.values.has(min)) return false; + if (!cron.hour.values.has(hour)) return false; + if (!cron.month.values.has(month)) return false; + + // Unix cron: if both dom and dow are restricted, either can match (OR logic). + // If only one is restricted, that one must match. + const domRestricted = !cron.dom.isAll; + const dowRestricted = !cron.dow.isAll; + if (domRestricted && dowRestricted) { + if (!cron.dom.values.has(dom) && !cron.dow.values.has(dow)) return false; + } else if (domRestricted) { + if (!cron.dom.values.has(dom)) return false; + } else if (dowRestricted) { + if (!cron.dow.values.has(dow)) return false; + } + + return true; +} + +export function nextRuns(cron: ParsedCron, from: Date, n: number): Date[] { + const results: Date[] = []; + // Round up to the next minute boundary. + const start = new Date(from); + start.setSeconds(0, 0); + start.setMinutes(start.getMinutes() + 1); + + const limit = new Date(start); + limit.setFullYear(limit.getFullYear() + 4); // search up to 4 years out + + const cur = new Date(start); + while (cur < limit && results.length < n) { + if (matchesCron(cron, cur)) results.push(new Date(cur)); + cur.setMinutes(cur.getMinutes() + 1); + } + return results; +} + +// ─── Field labels ──────────────────────────────────────────────────────────── + +export const FIELD_LABELS = [ + { key: 'minute', label: 'Minute', hint: '0–59' }, + { key: 'hour', label: 'Hour', hint: '0–23' }, + { key: 'dom', label: 'Day (month)', hint: '1–31' }, + { key: 'month', label: 'Month', hint: '1–12' }, + { key: 'dow', label: 'Weekday', hint: '0–7 (Sun=0,7)' }, +] as const; + +// ─── Presets ───────────────────────────────────────────────────────────────── + +export const CRON_PRESETS: { label: string; expr: string }[] = [ + { label: 'Every minute', expr: '* * * * *' }, + { label: 'Every 5 min', expr: '*/5 * * * *' }, + { label: 'Every 15 min', expr: '*/15 * * * *' }, + { label: 'Every 30 min', expr: '*/30 * * * *' }, + { label: 'Every hour', expr: '0 * * * *' }, + { label: 'Every 6 hours', expr: '0 */6 * * *' }, + { label: 'Midnight daily', expr: '0 0 * * *' }, + { label: 'Noon daily', expr: '0 12 * * *' }, + { label: 'Every weekday', expr: '0 9 * * 1-5' }, + { label: 'Every weekend', expr: '0 10 * * 0,6' }, + { label: 'Weekly (Mon)', expr: '0 0 * * 1' }, + { label: 'Monthly (1st)', expr: '0 0 1 * *' }, + { label: 'Yearly (Jan 1)', expr: '0 0 1 1 *' }, +];