Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/core/i18n/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "Jeden Sonntag",
"preset_every_monday_9am": "Jeden Montag um 9 Uhr",
"preset_every_month_first": "Jeden Monat am 1.",
"invalid_expression": "Ungültiger Cron-Ausdruck."
"invalid_expression": "Ungültiger Cron-Ausdruck.",
"field_seconds": "Sekunden",
"hint_seconds": "0-59 Sek."
},
"user_agent_parser": {
"title": "User-Agent-Parser",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat (Ausgestellt am)",
"claim_iss": "iss (Aussteller)",
"claim_sub": "sub (Betreff)",
"claim_aud": "aud (Zielgruppe)"
"claim_aud": "aud (Zielgruppe)",
"result_unsupported": "Nicht unterstuetzter JWT-Algorithmus ({alg}). Nutze JWT Workbench oder den JWKS-Verifikationsablauf fuer RS*-, ES*-, PS*- oder EdDSA-Tokens.",
"result_none_warning": "Dieses Token deklariert alg: none. Behandle es als unsigniert; dieser Verifier markiert es nicht per Signatur als gueltig oder ungueltig."
},
"jwt_workbench": {
"title": "JWT-Workbench",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,9 @@
"preset_every_sunday": "Every Sunday",
"preset_every_monday_9am": "Every Monday at 9AM",
"preset_every_month_first": "Every Month on the 1st",
"invalid_expression": "Invalid cron expression."
"invalid_expression": "Invalid cron expression.",
"field_seconds": "Seconds",
"hint_seconds": "0-59 seconds"
},
"user_agent_parser": {
"title": "User-Agent Parser",
Expand Down Expand Up @@ -1552,7 +1554,9 @@
"claim_iat": "iat (Issued At)",
"claim_iss": "iss (Issuer)",
"claim_sub": "sub (Subject)",
"claim_aud": "aud (Audience)"
"claim_aud": "aud (Audience)",
"result_unsupported": "Unsupported JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.",
"result_none_warning": "This token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature."
},
"jwt_workbench": {
"title": "JWT Workbench",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "Chaque dimanche",
"preset_every_monday_9am": "Chaque lundi à 9 h",
"preset_every_month_first": "Le 1er de chaque mois",
"invalid_expression": "Expression cron invalide."
"invalid_expression": "Expression cron invalide.",
"field_seconds": "Secondes",
"hint_seconds": "0-59 s"
},
"user_agent_parser": {
"title": "Analyseur User-Agent",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat (Émis à)",
"claim_iss": "iss (Émetteur)",
"claim_sub": "sub (Sujet)",
"claim_aud": "aud (Destinataire)"
"claim_aud": "aud (Destinataire)",
"result_unsupported": "Algorithme JWT non pris en charge ({alg}). Utilisez JWT Workbench ou le flux de verification JWKS pour les tokens RS*, ES*, PS* ou EdDSA.",
"result_none_warning": "Ce token declare alg: none. Traitez-le comme non signe ; ce verificateur ne le marque pas valide ou invalide par signature."
},
"jwt_workbench": {
"title": "Atelier JWT",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "毎週日曜",
"preset_every_monday_9am": "毎週月曜 9:00",
"preset_every_month_first": "毎月 1 日",
"invalid_expression": "無効な cron 式です。"
"invalid_expression": "無効な cron 式です。",
"field_seconds": "?",
"hint_seconds": "0-59 sec"
},
"user_agent_parser": {
"title": "User-Agent 解析",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat(発行時刻)",
"claim_iss": "iss(発行者)",
"claim_sub": "sub(件名)",
"claim_aud": "aud(対象者)"
"claim_aud": "aud(対象者)",
"result_unsupported": "Mitaio JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.",
"result_none_warning": "Kono token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature."
},
"jwt_workbench": {
"title": "JWTワークベンチ",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "매주 일요일",
"preset_every_monday_9am": "매주 월요일 오전 9시",
"preset_every_month_first": "매월 1일",
"invalid_expression": "유효하지 않은 cron 표현식입니다."
"invalid_expression": "유효하지 않은 cron 표현식입니다.",
"field_seconds": "?",
"hint_seconds": "0-59 sec"
},
"user_agent_parser": {
"title": "User-Agent 파서",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat(발급 시각)",
"claim_iss": "iss(발급자)",
"claim_sub": "sub(주체)",
"claim_aud": "aud(대상자)"
"claim_aud": "aud(대상자)",
"result_unsupported": "Jiwonhaji anneun JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.",
"result_none_warning": "I token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature."
},
"jwt_workbench": {
"title": "JWT 워크벤치",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "每周日",
"preset_every_monday_9am": "每周一 9 点",
"preset_every_month_first": "每月 1 日",
"invalid_expression": "无效的 cron 表达式。"
"invalid_expression": "无效的 cron 表达式。",
"field_seconds": "?",
"hint_seconds": "0-59 sec"
},
"user_agent_parser": {
"title": "User-Agent 解析器",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat(签发时间)",
"claim_iss": "iss(签发方)",
"claim_sub": "sub(主题)",
"claim_aud": "aud(受众)"
"claim_aud": "aud(受众)",
"result_unsupported": "Bu zhichi JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.",
"result_none_warning": "Ci token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature."
},
"jwt_workbench": {
"title": "JWT 工作台",
Expand Down
8 changes: 6 additions & 2 deletions src/core/i18n/translations/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1217,7 +1217,9 @@
"preset_every_sunday": "每週日",
"preset_every_monday_9am": "每週一 9 點",
"preset_every_month_first": "每月 1 日",
"invalid_expression": "無效的 cron 表達式。"
"invalid_expression": "無效的 cron 表達式。",
"field_seconds": "?",
"hint_seconds": "0-59 sec"
},
"user_agent_parser": {
"title": "User-Agent 解析器",
Expand Down Expand Up @@ -1524,7 +1526,9 @@
"claim_iat": "iat(簽發時間)",
"claim_iss": "iss(簽發者)",
"claim_sub": "sub(主體)",
"claim_aud": "aud(受眾)"
"claim_aud": "aud(受眾)",
"result_unsupported": "Bu zhiyuan JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.",
"result_none_warning": "Ci token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature."
},
"jwt_workbench": {
"title": "JWT 工作台",
Expand Down
40 changes: 40 additions & 0 deletions src/core/jwt/base64url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/

export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}

export function base64UrlToBytes(input: string): Uint8Array {
if (!BASE64URL_PATTERN.test(input) || input.length % 4 === 1) {
throw new Error("invalid_base64url")
}

const base64 = input
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(input.length / 4) * 4, "=")
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return bytes
}

export function textToBase64Url(value: string): string {
return bytesToBase64Url(new TextEncoder().encode(value))
}

export function base64UrlToText(input: string): string {
return new TextDecoder().decode(base64UrlToBytes(input))
}

export function jsonToBase64Url(value: unknown): string {
return textToBase64Url(JSON.stringify(value))
}

export function base64UrlToJson<T = unknown>(input: string): T {
return JSON.parse(base64UrlToText(input)) as T
}
80 changes: 80 additions & 0 deletions src/features/tools/crontab-generator/logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type CronFieldKey = "seconds" | "minute" | "hour" | "day_month" | "month" | "day_week"

export type CronFieldDefinition = {
key: CronFieldKey
min: number
max: number
}

export type CronValidationResult =
| { ok: true; parts: string[]; hasSeconds: boolean; normalized: string }
| { ok: false; error: string }

export const FIVE_FIELD_CRON_DEFINITIONS: CronFieldDefinition[] = [
{ key: "minute", min: 0, max: 59 },
{ key: "hour", min: 0, max: 23 },
{ key: "day_month", min: 1, max: 31 },
{ key: "month", min: 1, max: 12 },
{ key: "day_week", min: 0, max: 6 },
]

export const SIX_FIELD_CRON_DEFINITIONS: CronFieldDefinition[] = [
{ key: "seconds", min: 0, max: 59 },
...FIVE_FIELD_CRON_DEFINITIONS,
]

export function parseCronParts(expression: string): string[] {
const trimmed = expression.trim()
return trimmed ? trimmed.split(/\s+/) : []
}

export function normalizeCronExpression(expression: string): string {
return parseCronParts(expression).join(" ")
}

export function getCronFieldDefinitions(partCount: number): CronFieldDefinition[] {
return partCount === 6 ? SIX_FIELD_CRON_DEFINITIONS : FIVE_FIELD_CRON_DEFINITIONS
}

function getFieldErrorLabel(field: CronFieldDefinition): string {
return field.key.replace("_", " ")
}

function validateNumericTokens(field: string, definition: CronFieldDefinition): string | null {
const numericTokens = field.match(/\d+/g) ?? []
for (const token of numericTokens) {
const value = Number(token)
if (!Number.isInteger(value) || value < definition.min || value > definition.max) {
return `Invalid cron ${getFieldErrorLabel(definition)} field. Expected ${definition.min}-${definition.max}, got ${token}.`
}
}
return null
}

export function validateCronExpression(expression: string, genericError: string): CronValidationResult {
const parts = parseCronParts(expression)
if (parts.length !== 5 && parts.length !== 6) {
return { ok: false, error: genericError }
}

const definitions = getCronFieldDefinitions(parts.length)
for (let index = 0; index < definitions.length; index += 1) {
const fieldError = validateNumericTokens(parts[index] ?? "", definitions[index])
if (fieldError) return { ok: false, error: fieldError }
}

return {
ok: true,
parts,
hasSeconds: parts.length === 6,
normalized: parts.join(" "),
}
}

export function updateCronPart(expression: string, index: number, value: string): string {
const parts = parseCronParts(expression)
const targetLength = parts.length === 6 ? 6 : 5
while (parts.length < targetLength) parts.push("*")
parts[index] = value.trim() || "*"
return parts.slice(0, targetLength).join(" ")
}
68 changes: 41 additions & 27 deletions src/features/tools/crontab-generator/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { Input } from "@/components/ui/input"
import { safeClipboardWrite } from "@/core/clipboard/clipboard"
import type { Locale } from "@/core/i18n/i18n"
import cronstrue from "cronstrue/i18n.js"
import {
getCronFieldDefinitions,
parseCronParts,
updateCronPart,
validateCronExpression,
type CronFieldKey,
} from "./logic"

const CRONSTRUE_LOCALE_BY_LANG: Record<Locale, string> = {
en: "en",
Expand Down Expand Up @@ -36,13 +43,24 @@ export function CrontabGeneratorPage() {
const [description, setDescription] = React.useState("")
const [error, setError] = React.useState<string | null>(null)

// Parse the cron string to parts
const parts = cronString.split(" ")
const pMinute = parts[0] || ""
const pHour = parts[1] || ""
const pDom = parts[2] || ""
const pMonth = parts[3] || ""
const pDow = parts[4] || ""
const parts = parseCronParts(cronString)
const fieldDefinitions = getCronFieldDefinitions(parts.length)
const fieldLabelByKey: Record<CronFieldKey, string> = {
seconds: toolT.field_seconds,
minute: toolT.field_minute,
hour: toolT.field_hour,
day_month: toolT.field_day_month,
month: toolT.field_month,
day_week: toolT.field_day_week,
}
const fieldHintByKey: Record<CronFieldKey, string> = {
seconds: toolT.hint_seconds,
minute: toolT.hint_minute,
hour: toolT.hint_hour,
day_month: toolT.hint_day_month,
month: toolT.hint_month,
day_week: toolT.hint_day_week,
}

React.useEffect(() => {
if (!cronString.trim()) {
Expand All @@ -52,21 +70,18 @@ export function CrontabGeneratorPage() {
}

try {
// Validate length roughly
const parts = cronString.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 6) {
throw new Error(toolT.invalid_expression)
}
const validation = validateCronExpression(cronString, toolT.invalid_expression)
if (!validation.ok) throw new Error(validation.error)

const humanReadable = cronstrue.toString(cronString, {
const humanReadable = cronstrue.toString(validation.normalized, {
use24HourTimeFormat: true,
locale: cronLocale,
})
setDescription(humanReadable)
setError(null)
} catch {
} catch (err) {
setDescription("")
setError(toolT.invalid_expression)
setError(err instanceof Error ? err.message : toolT.invalid_expression)
}
}, [cronLocale, cronString, toolT.invalid_expression])

Expand All @@ -87,12 +102,7 @@ export function CrontabGeneratorPage() {
}

const updatePart = (index: number, val: string) => {
const p = cronString.split(" ")
// Fill array if too short
while (p.length < 5) p.push("*")

p[index] = val || "*"
setCronString(p.slice(0, 5).join(" "))
setCronString(updateCronPart(cronString, index, val))
}

return (
Expand Down Expand Up @@ -139,12 +149,16 @@ export function CrontabGeneratorPage() {
</div>

{/* Editor Grid */}
<div className="grid grid-cols-5 gap-2 sm:gap-4 md:px-0">
<CronPartInput label={toolT.field_minute} value={pMinute} onChange={(v) => updatePart(0, v)} hint={toolT.hint_minute} />
<CronPartInput label={toolT.field_hour} value={pHour} onChange={(v) => updatePart(1, v)} hint={toolT.hint_hour} />
<CronPartInput label={toolT.field_day_month} value={pDom} onChange={(v) => updatePart(2, v)} hint={toolT.hint_day_month} />
<CronPartInput label={toolT.field_month} value={pMonth} onChange={(v) => updatePart(3, v)} hint={toolT.hint_month} />
<CronPartInput label={toolT.field_day_week} value={pDow} onChange={(v) => updatePart(4, v)} hint={toolT.hint_day_week} />
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-4 lg:grid-cols-6 md:px-0">
{fieldDefinitions.map((field, index) => (
<CronPartInput
key={field.key}
label={fieldLabelByKey[field.key]}
value={parts[index] || ""}
onChange={(v) => updatePart(index, v)}
hint={fieldHintByKey[field.key]}
/>
))}
</div>

<div className="space-y-2 mt-4">
Expand Down
Loading