370 lines
8.5 KiB
Svelte
370 lines
8.5 KiB
Svelte
<svelte:head>
|
||
<title>Crontab Generator</title>
|
||
</svelte:head>
|
||
|
||
<script lang="js">
|
||
import { copy } from '$lib/clipboard.js';
|
||
|
||
/**
|
||
* @typedef {{ label: string, value: string }[]} Presets
|
||
*/
|
||
|
||
/** @type {{ label: string, name: string, placeholder: string, presets: Presets }[]} */
|
||
const fields = [
|
||
{
|
||
label: 'Minute',
|
||
name: 'minute',
|
||
placeholder: '0-59',
|
||
presets: [
|
||
{ label: '*', value: '*' },
|
||
{ label: '*/5', value: '*/5' },
|
||
{ label: '*/10', value: '*/10' },
|
||
{ label: '*/15', value: '*/15' },
|
||
{ label: '*/30', value: '*/30' },
|
||
{ label: '0', value: '0' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Hour',
|
||
name: 'hour',
|
||
placeholder: '0-23',
|
||
presets: [
|
||
{ label: '*', value: '*' },
|
||
{ label: '*/2', value: '*/2' },
|
||
{ label: '*/4', value: '*/4' },
|
||
{ label: '*/6', value: '*/6' },
|
||
{ label: '*/12', value: '*/12' },
|
||
{ label: '0', value: '0' },
|
||
{ label: '6', value: '6' },
|
||
{ label: '12', value: '12' },
|
||
{ label: '18', value: '18' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Day (month)',
|
||
name: 'dom',
|
||
placeholder: '1-31',
|
||
presets: [
|
||
{ label: '*', value: '*' },
|
||
{ label: '1', value: '1' },
|
||
{ label: '15', value: '15' },
|
||
{ label: 'L', value: 'L' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Month',
|
||
name: 'month',
|
||
placeholder: '1-12',
|
||
presets: [
|
||
{ label: '*', value: '*' },
|
||
{ label: '1', value: '1' },
|
||
{ label: '3', value: '3' },
|
||
{ label: '6', value: '6' },
|
||
{ label: '9', value: '9' },
|
||
{ label: '12', value: '12' },
|
||
{ label: '1-6', value: '1-6' },
|
||
{ label: '7-12', value: '7-12' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Day (week)',
|
||
name: 'dow',
|
||
placeholder: '0-7',
|
||
presets: [
|
||
{ label: '*', value: '*' },
|
||
{ label: 'Mon–Fri', value: '1-5' },
|
||
{ label: 'Sat–Sun', value: '6,0' },
|
||
{ label: 'Mon', value: '1' },
|
||
{ label: 'Wed', value: '3' },
|
||
{ label: 'Fri', value: '5' },
|
||
{ label: 'Sat', value: '6' },
|
||
{ label: 'Sun', value: '0' },
|
||
],
|
||
},
|
||
];
|
||
|
||
/** @type {Record<string, string>} */
|
||
let values = $state({
|
||
minute: '*',
|
||
hour: '*',
|
||
dom: '*',
|
||
month: '*',
|
||
dow: '*',
|
||
});
|
||
|
||
const expression = $derived(
|
||
`${values.minute} ${values.hour} ${values.dom} ${values.month} ${values.dow}`
|
||
);
|
||
|
||
/** @param {string} field @param {string} val */
|
||
const set = (field, val) => { values[field] = val; };
|
||
|
||
const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
const MON_NAMES = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
||
/** @param {string} v @returns {string} */
|
||
function describeMinute(v) {
|
||
if (v === '*') return 'every minute';
|
||
if (v === '0') return 'at minute 0';
|
||
if (v.startsWith('*/')) return `every ${v.slice(2)} minutes`;
|
||
return `at minute ${v}`;
|
||
}
|
||
|
||
/** @param {string} v @returns {string} */
|
||
function describeHour(v) {
|
||
if (v === '*') return 'every hour';
|
||
if (v.startsWith('*/')) return `every ${v.slice(2)} hours`;
|
||
if (/^\d+$/.test(v)) return `at ${v.padStart(2, '0')}:xx`;
|
||
return `at hour ${v}`;
|
||
}
|
||
|
||
/** @param {string} v @returns {string} */
|
||
function describeDom(v) {
|
||
if (v === '*') return '';
|
||
if (v === 'L') return 'on the last day of the month';
|
||
if (/^\d+$/.test(v)) return `on day ${v} of the month`;
|
||
return `on day-of-month ${v}`;
|
||
}
|
||
|
||
/** @param {string} v @returns {string} */
|
||
function describeMonth(v) {
|
||
if (v === '*') return '';
|
||
if (/^\d+$/.test(v)) return `in ${MON_NAMES[+v] ?? v}`;
|
||
if (v.includes('-')) {
|
||
const [a, b] = v.split('-');
|
||
return `from ${MON_NAMES[+a] ?? a} to ${MON_NAMES[+b] ?? b}`;
|
||
}
|
||
return `in month ${v}`;
|
||
}
|
||
|
||
/** @param {string} v @returns {string} */
|
||
function describeDow(v) {
|
||
if (v === '*') return '';
|
||
if (v === '1-5') return 'on weekdays (Mon–Fri)';
|
||
if (v === '6,0' || v === '0,6') return 'on weekends';
|
||
if (/^\d+$/.test(v)) return `on ${DOW_NAMES[+v] ?? v}`;
|
||
if (v.includes('-')) {
|
||
const [a, b] = v.split('-');
|
||
return `from ${DOW_NAMES[+a] ?? a} to ${DOW_NAMES[+b] ?? b}`;
|
||
}
|
||
return `on day-of-week ${v}`;
|
||
}
|
||
|
||
const description = $derived((() => {
|
||
const min = describeMinute(values.minute);
|
||
const hr = describeHour(values.hour);
|
||
const dom = describeDom(values.dom);
|
||
const mon = describeMonth(values.month);
|
||
const dow = describeDow(values.dow);
|
||
|
||
let parts = [];
|
||
|
||
if (values.minute === '*' && values.hour === '*') {
|
||
parts.push('every minute');
|
||
} else if (values.minute === '0' && values.hour !== '*') {
|
||
parts.push(`at ${values.hour.padStart(2,'0')}:00`);
|
||
} else if (values.hour === '*') {
|
||
parts.push(min);
|
||
} else {
|
||
parts.push(`${min}, ${hr}`);
|
||
}
|
||
|
||
if (dom) parts.push(dom);
|
||
if (mon) parts.push(mon);
|
||
if (dow) parts.push(dow);
|
||
|
||
return parts.join(', ');
|
||
})());
|
||
|
||
/** Common presets */
|
||
const quickPresets = [
|
||
{ label: 'Every minute', expr: '* * * * *' },
|
||
{ label: 'Every 5 minutes', expr: '*/5 * * * *' },
|
||
{ label: 'Every 15 minutes', expr: '*/15 * * * *' },
|
||
{ label: 'Every 30 minutes', expr: '*/30 * * * *' },
|
||
{ label: 'Every hour', expr: '0 * * * *' },
|
||
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
|
||
{ label: 'Every day at midnight', expr: '0 0 * * *' },
|
||
{ label: 'Every day at noon', expr: '0 12 * * *' },
|
||
{ label: 'Every Mon at 09:00',expr: '0 9 * * 1' },
|
||
{ label: 'Weekdays at 08:00', expr: '0 8 * * 1-5' },
|
||
{ label: 'Every Sunday at 03:00', expr: '0 3 * * 0' },
|
||
{ label: '1st of each month', expr: '0 0 1 * *' },
|
||
{ label: 'Every quarter', expr: '0 0 1 */3 *' },
|
||
{ label: 'Once a year (Jan 1)',expr: '0 0 1 1 *' },
|
||
];
|
||
|
||
/** @param {string} expr */
|
||
function applyPreset(expr) {
|
||
const parts = expr.split(' ');
|
||
values.minute = parts[0];
|
||
values.hour = parts[1];
|
||
values.dom = parts[2];
|
||
values.month = parts[3];
|
||
values.dow = parts[4];
|
||
}
|
||
|
||
/** @param {string} expr */
|
||
function parseExpression(expr) {
|
||
const parts = expr.trim().split(/\s+/);
|
||
if (parts.length === 5) applyPreset(parts.join(' '));
|
||
}
|
||
|
||
let manualInput = $state('');
|
||
|
||
$effect(() => {
|
||
manualInput = expression;
|
||
});
|
||
</script>
|
||
|
||
<h1>Cron</h1>
|
||
|
||
<h2>Quick presets</h2>
|
||
<div class="presets">
|
||
{#each quickPresets as p}
|
||
<button onclick={() => applyPreset(p.expr)}>{p.label}</button>
|
||
{/each}
|
||
</div>
|
||
|
||
<h2>Builder</h2>
|
||
<table class="builder">
|
||
<thead>
|
||
<tr>
|
||
{#each fields as f}
|
||
<th>{f.label}</th>
|
||
{/each}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
{#each fields as f}
|
||
<td>
|
||
<input
|
||
type="text"
|
||
value={values[f.name]}
|
||
oninput={(e) => set(f.name, /** @type {HTMLInputElement} */ (e.target).value)}
|
||
size="6"
|
||
spellcheck="false"
|
||
/>
|
||
</td>
|
||
{/each}
|
||
</tr>
|
||
<tr>
|
||
{#each fields as f}
|
||
<td class="chips">
|
||
{#each f.presets as p}
|
||
<button
|
||
class:selected={values[f.name] === p.value}
|
||
onclick={() => set(f.name, p.value)}
|
||
>{p.label}</button>
|
||
{/each}
|
||
</td>
|
||
{/each}
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2>Expression</h2>
|
||
<p>
|
||
<code class="expr">{expression}</code>
|
||
<button onclick={() => copy(expression)}>copy</button>
|
||
</p>
|
||
<p class="desc">{description}</p>
|
||
|
||
<h2>Paste & parse</h2>
|
||
<p>
|
||
<input
|
||
type="text"
|
||
bind:value={manualInput}
|
||
size="30"
|
||
placeholder="* * * * *"
|
||
spellcheck="false"
|
||
oninput={() => parseExpression(manualInput)}
|
||
/>
|
||
<button onclick={() => parseExpression(manualInput)}>parse</button>
|
||
</p>
|
||
|
||
<h2>Reference</h2>
|
||
<table class="ref">
|
||
<thead>
|
||
<tr><th>Field</th><th>Range</th><th>Special</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr><td>Minute</td><td>0–59</td><td rowspan="5"><code>*</code> any <code>,</code> list <code>-</code> range <code>/</code> step</td></tr>
|
||
<tr><td>Hour</td><td>0–23</td></tr>
|
||
<tr><td>Day (month)</td><td>1–31</td></tr>
|
||
<tr><td>Month</td><td>1–12</td></tr>
|
||
<tr><td>Day (week)</td><td>0–7 (0,7=Sun)</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<style>
|
||
.presets {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.builder {
|
||
border-collapse: collapse;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.builder th,
|
||
.builder td {
|
||
border: 1px solid var(--border);
|
||
padding: 4px 8px;
|
||
vertical-align: top;
|
||
}
|
||
|
||
.builder th {
|
||
background: var(--bg-raised);
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.builder input {
|
||
width: 60px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 2px;
|
||
min-width: 80px;
|
||
}
|
||
|
||
.chips button.selected {
|
||
background: var(--active-bg);
|
||
color: var(--active-fg);
|
||
border-color: var(--active-bg);
|
||
}
|
||
|
||
.expr {
|
||
font-size: 1.3em;
|
||
margin-right: 8px;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.desc {
|
||
color: var(--fg-dim);
|
||
font-style: italic;
|
||
margin-top: 0;
|
||
}
|
||
|
||
.ref {
|
||
border-collapse: collapse;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.ref th,
|
||
.ref td {
|
||
border: 1px solid var(--border);
|
||
padding: 3px 8px;
|
||
}
|
||
|
||
.ref th {
|
||
background: var(--bg-raised);
|
||
}
|
||
</style>
|