From 231841c5574c75d77875a1cf707c111884a93cb0 Mon Sep 17 00:00:00 2001 From: Kresna Date: Fri, 7 Aug 2026 15:29:32 +0700 Subject: [PATCH] =?UTF-8?q?feat(dev):=20reverse=20cron=20=E2=80=94=20natur?= =?UTF-8?q?al=20language=20=E2=86=92=20cron=20expression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "describe your schedule" input above the cron editor. Type plain English like "every weekday at 9am" or "midnight on the 1st" and press Convert to instantly get the cron expression. - cron-natural.lib.ts: pure rule-based NL parser (no LLM, no server): interval shortcuts (every N min/hr), time extraction (midnight/noon/ HH:MM/am-pm), DOW (weekdays/weekends/named days/lists), DOM (ordinals), month names, yearly/monthly/weekly/daily patterns. - 86 unit tests across naturalToCron + 4 sub-extractors. - CronExplainer.tsx: "Describe your schedule" input + Convert button on top; Enter key submits; on success fills expression field + focuses it; error shown inline. Divider separates NL section from expression section. Co-Authored-By: Claude Sonnet 4.6 --- src/islands/dev/CronExplainer.tsx | 76 ++++++- src/tools/dev/cron-natural.lib.test.ts | 217 +++++++++++++++++++ src/tools/dev/cron-natural.lib.ts | 289 +++++++++++++++++++++++++ 3 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 src/tools/dev/cron-natural.lib.test.ts create mode 100644 src/tools/dev/cron-natural.lib.ts diff --git a/src/islands/dev/CronExplainer.tsx b/src/islands/dev/CronExplainer.tsx index a753022..9d8a291 100644 --- a/src/islands/dev/CronExplainer.tsx +++ b/src/islands/dev/CronExplainer.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo } from 'react'; -import { CalendarClock } from 'lucide-react'; +import { useState, useMemo, useRef } from 'react'; +import { CalendarClock, ArrowDown } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { CopyButton } from '@/components/ui/CopyButton'; import { Alert } from '@/components/ui/Alert'; @@ -7,6 +7,7 @@ import { parseCron, explainCron, nextRuns, FIELD_LABELS, CRON_PRESETS, } from '@/tools/dev/cron.lib'; +import { naturalToCron, NATURAL_EXAMPLES } from '@/tools/dev/cron-natural.lib'; import type { Lang } from '@/i18n/config'; const TR: Record = { en: { exprLabel: 'Cron expression', @@ -25,9 +29,12 @@ const TR: Record(null); const parsed = useMemo(() => parseCron(expr), [expr]); const explanation = useMemo(() => { @@ -65,15 +78,60 @@ export default function CronExplainer({ lang = 'en' }: { lang?: Lang }) { const parts = expr.trim().split(/\s+/); const fieldParts = parts.length === 5 ? parts : ['*', '*', '*', '*', '*']; + const handleConvert = () => { + setNlError(''); + const result = naturalToCron(nlInput); + if (result.ok) { + setExpr(result.expr); + exprRef.current?.focus(); + } else { + setNlError(result.error); + } + }; + + // Cycle through example hints on placeholder click (accessibility: shows examples) + const exampleHint = NATURAL_EXAMPLES[Math.floor((Date.now() / 4000)) % NATURAL_EXAMPLES.length]; + return (
- {/* Main input */} + {/* Reverse: natural language → cron */} +
+ +
+ { setNlInput(e.target.value); setNlError(''); }} + onKeyDown={e => { if (e.key === 'Enter') handleConvert(); }} + placeholder={t.describePlaceholder} + className="flex-1 border-2 border-border bg-background px-3 py-2 text-sm outline-none focus:shadow-brutal-sm" + aria-label={t.describeLabel} + /> + +
+ {nlError && {nlError}} +

e.g. {exampleHint}

+
+ + {/* Divider */} +
+
+ {t.dividerOr} +
+
+ + {/* Main expression input */}
setExpr(e.target.value)} spellCheck={false} diff --git a/src/tools/dev/cron-natural.lib.test.ts b/src/tools/dev/cron-natural.lib.test.ts new file mode 100644 index 0000000..3fe3562 --- /dev/null +++ b/src/tools/dev/cron-natural.lib.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; +import { naturalToCron, extractTime, extractDow, extractDom, extractMonth } from './cron-natural.lib'; + +function expr(input: string): string { + const r = naturalToCron(input); + if (!r.ok) throw new Error(`Parse failed for "${input}": ${r.error}`); + return r.expr; +} + +// ─── naturalToCron — interval patterns ────────────────────────────────────── + +describe('naturalToCron — intervals', () => { + it.each([ + ['every minute', '* * * * *'], + ['Every Minute', '* * * * *'], + ['every 5 minutes', '*/5 * * * *'], + ['every 5 min', '*/5 * * * *'], + ['every 15 minutes', '*/15 * * * *'], + ['every 30 minutes', '*/30 * * * *'], + ['every hour', '0 * * * *'], + ['hourly', '0 * * * *'], + ['every 2 hours', '0 */2 * * *'], + ['every 6 hours', '0 */6 * * *'], + ['every 12 hours', '0 */12 * * *'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); + + it('rejects minute step out of range', () => { + const r = naturalToCron('every 60 minutes'); + expect(r.ok).toBe(false); + }); + + it('rejects hour step out of range', () => { + const r = naturalToCron('every 24 hours'); + expect(r.ok).toBe(false); + }); +}); + +// ─── naturalToCron — daily patterns ───────────────────────────────────────── + +describe('naturalToCron — daily', () => { + it.each([ + ['daily at midnight', '0 0 * * *'], + ['every day at midnight','0 0 * * *'], + ['midnight daily', '0 0 * * *'], + ['noon every day', '0 12 * * *'], + ['daily at noon', '0 12 * * *'], + ['every day at 9am', '0 9 * * *'], + ['every day at 9:30am', '30 9 * * *'], + ['daily at 14:00', '0 14 * * *'], + ['at 3pm every day', '0 15 * * *'], + ['at 9', '0 9 * * *'], + ['at midnight', '0 0 * * *'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — weekday / weekend ────────────────────────────────────── + +describe('naturalToCron — weekdays/weekends', () => { + it.each([ + ['every weekday at 9am', '0 9 * * 1-5'], + ['weekdays at 9am', '0 9 * * 1-5'], + ['monday through friday at 8', '0 8 * * 1-5'], + ['every weekend at 10am', '0 10 * * 0,6'], + ['weekends at midnight', '0 0 * * 0,6'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — specific days ────────────────────────────────────────── + +describe('naturalToCron — specific weekdays', () => { + it.each([ + ['every monday at 9am', '0 9 * * 1'], + ['every Monday', '0 0 * * 1'], + ['every friday at noon', '0 12 * * 5'], + ['every sunday at midnight', '0 0 * * 0'], + ['every monday and wednesday at 8am','0 8 * * 1,3'], + ['every tuesday thursday at 6pm', '0 18 * * 2,4'], + ['every mon wed fri at 9am', '0 9 * * 1,3,5'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — monthly ───────────────────────────────────────────────── + +describe('naturalToCron — monthly / dom', () => { + it.each([ + ['monthly', '0 0 1 * *'], + ['every month', '0 0 1 * *'], + ['monthly on the 1st', '0 0 1 * *'], + ['every month on the 15th', '0 0 15 * *'], + ['on the 1st at midnight', '0 0 1 * *'], + ['on the 15th at noon', '0 12 15 * *'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — yearly ────────────────────────────────────────────────── + +describe('naturalToCron — yearly', () => { + it.each([ + ['yearly', '0 0 1 1 *'], + ['annually', '0 0 1 1 *'], + ['every year', '0 0 1 1 *'], + ['every year on January 1st', '0 0 1 1 *'], + ['every January 1st at midnight', '0 0 1 1 *'], + ['on January 15th at 9am', '0 9 15 1 *'], + ['every december 25th', '0 0 25 12 *'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — weekly ─────────────────────────────────────────────────── + +describe('naturalToCron — weekly', () => { + it.each([ + ['weekly', '0 0 * * 0'], + ['every week', '0 0 * * 0'], + ['every week at noon', '0 12 * * 0'], + ])('%s → %s', (input, expected) => { + expect(expr(input)).toBe(expected); + }); +}); + +// ─── naturalToCron — error cases ───────────────────────────────────────────── + +describe('naturalToCron — errors', () => { + it('returns ok:false for empty input', () => { + expect(naturalToCron('').ok).toBe(false); + }); + + it('returns ok:false for unrecognisable text', () => { + expect(naturalToCron('random gibberish xyz').ok).toBe(false); + }); +}); + +// ─── Sub-extractors ────────────────────────────────────────────────────────── + +describe('extractTime', () => { + it.each([ + ['midnight', { hour: 0, minute: 0 }], + ['noon', { hour: 12, minute: 0 }], + ['midday', { hour: 12, minute: 0 }], + ['at 9am', { hour: 9, minute: 0 }], + ['at 9:30am', { hour: 9, minute: 30 }], + ['at 3pm', { hour: 15, minute: 0 }], + ['at 12:00pm', { hour: 12, minute: 0 }], + ['at 12:00am', { hour: 0, minute: 0 }], + ['14:30', { hour: 14, minute: 30 }], + ['at 0', { hour: 0, minute: 0 }], + ])('%s → %j', (text, expected) => { + expect(extractTime(text)).toEqual(expected); + }); + + it('returns null when no time is present', () => { + expect(extractTime('every monday')).toBeNull(); + }); +}); + +describe('extractDow', () => { + it.each([ + ['weekdays', '1-5'], + ['weekends', '0,6'], + ['monday', '1'], + ['sunday', '0'], + ['monday and wednesday', '1,3'], + ['tue thu', '2,4'], + ['mon wed fri', '1,3,5'], + ])('%s → %s', (text, expected) => { + expect(extractDow(text)).toBe(expected); + }); + + it('returns null with no day names', () => { + expect(extractDow('at 9am daily')).toBeNull(); + }); +}); + +describe('extractDom', () => { + it.each([ + ['on the 1st', 1], + ['on the 15th', 15], + ['on the 31st', 31], + ['on the first', 1], + ['fifteenth', 15], + ['day 10', 10], + ])('%s → %d', (text, expected) => { + expect(extractDom(text)).toBe(expected); + }); + + it('returns null with no ordinal', () => { + expect(extractDom('every day at 9am')).toBeNull(); + }); +}); + +describe('extractMonth', () => { + it.each([ + ['january', 1], + ['jan', 1], + ['december', 12], + ['dec', 12], + ['august', 8], + ])('%s → %d', (text, expected) => { + expect(extractMonth(text)).toBe(expected); + }); + + it('returns null with no month', () => { + expect(extractMonth('every monday')).toBeNull(); + }); +}); diff --git a/src/tools/dev/cron-natural.lib.ts b/src/tools/dev/cron-natural.lib.ts new file mode 100644 index 0000000..b9a133a --- /dev/null +++ b/src/tools/dev/cron-natural.lib.ts @@ -0,0 +1,289 @@ +/** + * Reverse cron: convert a plain-English schedule description to a 5-field + * cron expression. Pure rule-based NLP — no AI, no server, runs client-side. + * + * Handles the most common scheduling patterns: + * "every 15 minutes", "every weekday at 9am", "midnight on the 1st", etc. + */ + +// ─── Lookup tables ─────────────────────────────────────────────────────────── + +const DOW_MAP: Record = { + sun: 0, sunday: 0, sundays: 0, + mon: 1, monday: 1, mondays: 1, + tue: 2, tues: 2, tuesday: 2, tuesdays: 2, + wed: 3, weds: 3, wednesday: 3, wednesdays: 3, + thu: 4, thur: 4, thurs: 4, thursday: 4, thursdays: 4, + fri: 5, friday: 5, fridays: 5, + sat: 6, saturday: 6, saturdays: 6, +}; + +const MONTH_MAP: Record = { + jan: 1, january: 1, + feb: 2, february: 2, + mar: 3, march: 3, + apr: 4, april: 4, + may: 5, + jun: 6, june: 6, + jul: 7, july: 7, + aug: 8, august: 8, + sep: 9, sept: 9, september: 9, + oct: 10, october: 10, + nov: 11, november: 11, + dec: 12, december: 12, +}; + +// Ordinal word/suffix → day-of-month number +const ORDINAL_MAP: Record = { + '1st': 1, first: 1, + '2nd': 2, second: 2, + '3rd': 3, third: 3, + '4th': 4, fourth: 4, + '5th': 5, fifth: 5, + '6th': 6, sixth: 6, + '7th': 7, seventh: 7, + '8th': 8, eighth: 8, + '9th': 9, ninth: 9, + '10th': 10, tenth: 10, + '11th': 11, eleventh: 11, + '12th': 12, twelfth: 12, + '13th': 13, thirteenth: 13, + '14th': 14, fourteenth: 14, + '15th': 15, fifteenth: 15, + '16th': 16, sixteenth: 16, + '17th': 17, seventeenth: 17, + '18th': 18, eighteenth: 18, + '19th': 19, nineteenth: 19, + '20th': 20, twentieth: 20, + '21st': 21, 'twenty-first': 21, + '22nd': 22, 'twenty-second': 22, + '23rd': 23, 'twenty-third': 23, + '24th': 24, 'twenty-fourth': 24, + '25th': 25, 'twenty-fifth': 25, + '26th': 26, 'twenty-sixth': 26, + '27th': 27, 'twenty-seventh': 27, + '28th': 28, 'twenty-eighth': 28, + '29th': 29, 'twenty-ninth': 29, + '30th': 30, thirtieth: 30, + '31st': 31, 'thirty-first': 31, +}; + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export type NaturalParseResult = + | { ok: true; expr: string } + | { ok: false; error: string }; + +/** + * Convert a plain-English schedule description to a cron expression. + * Returns ok:false when the description doesn't match any known pattern. + */ +export function naturalToCron(input: string): NaturalParseResult { + const raw = input.trim(); + if (!raw) return { ok: false, error: 'Empty input' }; + + const text = raw.toLowerCase() + .replace(/[,;]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + // ── 1. Pure-interval shortcuts ────────────────────────────────────────── + + if (/\bevery\s+minute\b/.test(text)) return { ok: true, expr: '* * * * *' }; + + const minuteStep = text.match(/\bevery\s+(\d+)\s+min(?:ute)?s?\b/); + if (minuteStep) { + const n = parseInt(minuteStep[1]); + if (n < 2 || n > 59) return { ok: false, error: `Minute interval must be 2–59 (got ${n})` }; + return { ok: true, expr: `*/${n} * * * *` }; + } + + if (/\b(?:every\s+hour|hourly)\b/.test(text)) return { ok: true, expr: '0 * * * *' }; + + const hourStep = text.match(/\bevery\s+(\d+)\s+hours?\b/); + if (hourStep) { + const n = parseInt(hourStep[1]); + if (n < 2 || n > 23) return { ok: false, error: `Hour interval must be 2–23 (got ${n})` }; + return { ok: true, expr: `0 */${n} * * *` }; + } + + // ── 2. Extract components ─────────────────────────────────────────────── + + const time = extractTime(text); + const dows = extractDow(text); + const dom = extractDom(text); + const month = extractMonth(text); + + const minutePart = time ? String(time.minute) : '0'; + const hourPart = time ? String(time.hour) : null; + + // ── 3. "yearly" / "annually" ──────────────────────────────────────────── + + if (/\b(?:yearly|annually|every\s+year)\b/.test(text)) { + const h = hourPart ?? '0'; + const d = dom ?? 1; + const mo = month ?? 1; + return { ok: true, expr: `${minutePart} ${h} ${d} ${mo} *` }; + } + + // ── 4. "monthly" / "every month" ──────────────────────────────────────── + + if (/\b(?:monthly|every\s+month)\b/.test(text) && !dows) { + const h = hourPart ?? '0'; + const d = dom ?? 1; + const mo = month ? String(month) : '*'; + return { ok: true, expr: `${minutePart} ${h} ${d} ${mo} *` }; + } + + // ── 5. Specific month → implies yearly ────────────────────────────────── + + if (month && !dows) { + const h = hourPart ?? '0'; + const d = dom ?? 1; + return { ok: true, expr: `${minutePart} ${h} ${d} ${month} *` }; + } + + // ── 6. "weekly" / "every week" ────────────────────────────────────────── + + if (/\b(?:weekly|every\s+week)\b/.test(text) && !dows) { + const h = hourPart ?? '0'; + return { ok: true, expr: `${minutePart} ${h} * * 0` }; + } + + // ── 7. Day-of-month specified ──────────────────────────────────────────── + + if (dom && !dows) { + const h = hourPart ?? '0'; + const mo = month ? String(month) : '*'; + return { ok: true, expr: `${minutePart} ${h} ${dom} ${mo} *` }; + } + + // ── 8. Day-of-week specified ───────────────────────────────────────────── + + if (dows) { + if (hourPart === null) { + // "every Monday" without a time → midnight + return { ok: true, expr: `0 0 * * ${dows}` }; + } + return { ok: true, expr: `${minutePart} ${hourPart} * * ${dows}` }; + } + + // ── 9. "daily" / "every day" ──────────────────────────────────────────── + + if (/\b(?:daily|every\s+day)\b/.test(text)) { + const h = hourPart ?? '0'; + return { ok: true, expr: `${minutePart} ${h} * * *` }; + } + + // ── 10. Time with no day restriction ──────────────────────────────────── + + if (time) { + return { ok: true, expr: `${minutePart} ${hourPart ?? '0'} * * *` }; + } + + return { ok: false, error: 'Could not parse — try "every 15 minutes", "weekdays at 9am", "1st of every month"' }; +} + +// ─── Component extractors ──────────────────────────────────────────────────── + +interface ParsedTime { hour: number; minute: number } + +export function extractTime(text: string): ParsedTime | null { + // "midnight" + if (/\bmidnight\b/.test(text)) return { hour: 0, minute: 0 }; + // "noon" / "midday" + if (/\b(?:noon|midday)\b/.test(text)) return { hour: 12, minute: 0 }; + + // "HH:MM am/pm" or "HH:MM" + const hhmm = text.match(/\b(\d{1,2}):(\d{2})\s*(am|pm)?\b/); + if (hhmm) { + let h = parseInt(hhmm[1]); + const m = parseInt(hhmm[2]); + if (hhmm[3] === 'pm' && h < 12) h += 12; + if (hhmm[3] === 'am' && h === 12) h = 0; + if (h >= 0 && h <= 23 && m >= 0 && m <= 59) return { hour: h, minute: m }; + } + + // "N am" / "N pm" (possibly preceded by "at") + const ampm = text.match(/\b(?:at\s+)?(\d{1,2})\s*(am|pm)\b/); + if (ampm) { + let h = parseInt(ampm[1]); + if (ampm[2] === 'pm' && h < 12) h += 12; + if (ampm[2] === 'am' && h === 12) h = 0; + if (h >= 0 && h <= 23) return { hour: h, minute: 0 }; + } + + // "at N" (bare number 0–23) + const atN = text.match(/\bat\s+(\d{1,2})\b/); + if (atN) { + const h = parseInt(atN[1]); + if (h >= 0 && h <= 23) return { hour: h, minute: 0 }; + } + + return null; +} + +/** Returns a cron DOW field string or null if no weekday constraint found. */ +export function extractDow(text: string): string | null { + // "weekday(s)" / "monday through friday" / "mon-fri" + if (/\bweekdays?\b/.test(text) || /\bmon(?:day)?\s+(?:through|to|-)\s+fri(?:day)?\b/.test(text)) { + return '1-5'; + } + // "weekend(s)" / "saturday and sunday" + if (/\bweekends?\b/.test(text) || /\b(?:sat(?:urday)?\s+and\s+sun(?:day)?|sun(?:day)?\s+and\s+sat(?:urday)?)\b/.test(text)) { + return '0,6'; + } + + // Scan for day name tokens (handles lists: "monday wednesday friday" or "monday and wednesday") + const tokens = text.replace(/\band\b/g, ' ').split(/\s+/); + const days: number[] = []; + for (const tok of tokens) { + if (tok in DOW_MAP) days.push(DOW_MAP[tok]); + } + if (days.length === 0) return null; + const unique = [...new Set(days)].sort((a, b) => a - b); + if (unique.length === 7) return '*'; + return unique.join(','); +} + +/** Returns a day-of-month number or null. */ +export function extractDom(text: string): number | null { + // "on the Nth" / "day N" / "Nth of the month" + for (const [word, n] of Object.entries(ORDINAL_MAP)) { + const re = new RegExp(`\\b${word}\\b`); + if (re.test(text)) return n; + } + // bare number after "day" — e.g. "day 15" + const dayN = text.match(/\bday\s+(\d{1,2})\b/); + if (dayN) { + const n = parseInt(dayN[1]); + if (n >= 1 && n <= 31) return n; + } + return null; +} + +/** Returns a month number (1–12) or null. */ +export function extractMonth(text: string): number | null { + for (const [word, n] of Object.entries(MONTH_MAP)) { + const re = new RegExp(`\\b${word}\\b`); + if (re.test(text)) return n; + } + return null; +} + +// ─── Example hints ──────────────────────────────────────────────────────────── + +export const NATURAL_EXAMPLES = [ + 'every minute', + 'every 15 minutes', + 'every hour', + 'every 6 hours', + 'daily at midnight', + 'every day at 9am', + 'every weekday at 9:30am', + 'every Monday at noon', + 'every Monday and Wednesday at 8am', + 'every weekend at 10am', + 'monthly on the 1st', + 'every year on January 1st', +];