Cron Expression Validator (e.g. for Schedule Metadata)

Open cron-expression-validator in Script Kit

I'm quite proud of this one. Super useful when you need to build // Schedule metadata. Also a nice showcase for using Signals in node apps.

Demo

https://github.com/johnlindquist/kit/assets/7313176/29beaabc-1703-4313-aa9b-f963ef5b52e3

// Name: Cron Expression Validator
// Description: Validates and helps you build Crontab expressions
// Shortcode: cron
// Author: @JosXa, loosely based on Ricardo Gonçalves Bassete's version
import "@johnlindquist/kit"
import { computed, effect, signal } from "@preact/signals-core"
import cronstrue from "cronstrue"
import { markdownTable } from "markdown-table"
const FONT_SIZE = "0.8em"
const allowedCharsTable = markdownTable(
[
["Character", "Meaning"],
["`*`", "any value"],
["`,`", "value list separator"],
["`-`", "range of values"],
["`/`", 'step values (e.g. `*/5 * * * *` for "every 5 minutes")'],
],
{ align: "l" },
)
const tableHtml = md(allowedCharsTable).replace('<th align="left">', '<th align="left" style="width: 17%">')
const input = signal("* * * * *")
const parts = computed(() => input.value.split(" "))
const parsedExpression = computed(() => {
try {
return cronstrue.toString(input.value)
} catch (err) {
return undefined
}
})
const isValid = computed(() => !!parsedExpression.value)
const asciiHint = computed(() => {
if (!input.value) {
return `
| | | | |
| | | | +----- day of the week (0 - 7) (Sunday = 0 or 7)
| | | +------- month (1 - 12)
| | +--------- day of the month (1 - 31)
| +----------- hour (0 - 23)
+------------- minute (0 - 59)
`.trim()
}
const hasSecond = parts.value.length >= 6
const names = [
"second (0 - 59)",
"minute (0 - 59)",
"hour (0 - 23)",
"day of the month (1 - 31)",
"month (1 - 12)",
"day of the week (0 - 7) (Sunday = 0 or 7)",
]
if (!hasSecond) {
names.splice(0, 1)
}
/*
0 1 2 3 4 5 👉 partIdx
0 | | | | | +----- day of the week (0 - 7) (Sunday = 0 or 7)
1 | | | | +------- month (1 - 12)
2 | | | +--------- day of the month (1 - 31)
3 | | +----------- hour (0 - 23)
4 | +------------- minute (0 - 59)
5 +--------------- second (0 - 59)
👇
lineIdx
*/
const columns = parts.value.reduce(
(agg, part, partIdx) => {
const prev = agg[partIdx - 1]
const startCol = prev ? prev.endCol + 1 : 0
const endCol = startCol + part.length
const name = names[partIdx]!
agg.push({
partIdx,
startCol,
endCol,
part,
gapToPrevious: Math.max(0, endCol - startCol),
name,
})
return agg
},
[] as Array<{
startCol: number
endCol: number
part: string
gapToPrevious: number
partIdx: number
name: string
}>,
)
const lines: string[] = []
const maxLen = columns.slice(-1)[0]!.endCol + 5
for (let lineIdx = -1; lineIdx < columns.length; lineIdx++) {
let line = ""
for (const { gapToPrevious, partIdx, name } of columns) {
if (lineIdx === -1) {
line += "|"
line += " ".repeat(gapToPrevious)
continue
}
if (partIdx + lineIdx === columns.length - 1) {
line += "+"
line = line.padEnd(maxLen, "-")
line += ` ${name}`
break
}
line += "|"
line += " ".repeat(gapToPrevious)
}
lines.push(line)
}
return lines.join("\n")
})
const asciiHintHtml = computed(() =>
`<div style="font-size: ${FONT_SIZE};" class="px-4"><pre>${asciiHint}</pre></div>`.trim(),
)
const resultMessage = computed(() => {
if (!input.value) {
return ""
}
return (
"<br>" +
(parsedExpression.value
? `<h3 class="px-4" style="color: rgba(var(--color-primary), var(--tw-text-opacity))">👉 ${parsedExpression.value}</h3>`
: `<h3 class="px-4" style="color: #f65671">❌ The expression "${input.value}" cannot be parsed.</h3>`)
)
})
const enter = computed(() => (isValid.value ? "Copy" : ""))
const panel = computed(() => `<div>${asciiHintHtml.value}${resultMessage.value}<br><hr>${tableHtml}</div>`)
const cleanup: Array<() => void> = []
await arg({
placeholder: "Type a Crontab expression",
input: input.value,
className: "p-0",
inputClassName: "font-mono",
css: `
#input {
min-width: 250px !important;
font-size: ${FONT_SIZE} !important;
}
`,
onInit() {
cleanup.push(effect(() => setEnter(enter.value)))
cleanup.push(effect(() => setPanel(panel.value)))
},
onInput(val) {
if (!val) {
input.value = ""
return
}
let sanitized = val
// Replace duplicate spaces
.replaceAll(/\s{2,}/g, " ")
// Leading whitespace
.replaceAll(/^\s+/g, "")
const s = sanitized.split(" ")
// Ensure maximum of 6 parts
if (s.length > 6) {
sanitized = s.slice(0, 6).join(" ")
}
if (val !== sanitized) {
setInput(sanitized)
}
input.value = sanitized
},
enter: enter.value,
alwaysOnTop: true,
})
cleanup.forEach((fn) => fn())
await clipboard.writeText(input.value)