diff --git a/README.md b/README.md index 64cf9cc..22e0a63 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,21 @@ Free, fast, browser-based developer tools. No sign-up. No data collection. **Live →** *coming soon* -## Tools +## Tools (30) -- JSON Formatter & Validator -- Markdown Preview -- Base64 Encode/Decode -- URL Encode/Decode -- HTML Entity Encode/Decode -- JWT Decoder -- UUID Generator -- Lorem Ipsum Generator -- Hash Generator (SHA-1/256/384/512) -- QR Code Generator -- Password Generator -- Unix Timestamp Converter -- Color Converter (HEX/RGB/HSL) -- Number Base Converter -- Word & Character Counter -- Regex Tester -- Text Diff Checker -- Case Converter -- CSS Gradient Generator -- CSS Box Shadow Generator +**Formatters** — JSON Formatter, Markdown Preview, SQL Formatter, XML Formatter + +**Encoders** — Base64, URL Encoder, HTML Entities, JWT Decoder + +**Generators** — UUID, Lorem Ipsum, Hash, QR Code, Password + +**Converters** — Timestamp, Color, Number Base, JSON→CSV, CSV→JSON + +**Text Tools** — Word Counter, Regex Tester, Diff Checker, Case Converter + +**CSS Tools** — Gradient Generator, Box Shadow, Px↔Rem Converter + +**Web Tools** — Meta Tag Generator, Slug Generator, Cron Parser, Image→Base64, Placeholder Image ## Stack diff --git a/src/app/tools/cron-parser/page.tsx b/src/app/tools/cron-parser/page.tsx new file mode 100644 index 0000000..d2772ed --- /dev/null +++ b/src/app/tools/cron-parser/page.tsx @@ -0,0 +1,1015 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import ToolLayout from "@/components/ToolLayout"; + +interface FieldDefinition { + name: string; + key: string; + min: number; + max: number; + namesMap?: Record; + reverseNamesMap?: Record; + description: string; +} + +const MONTH_NAMES: Record = { + JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, + JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, +}; + +const MONTH_NAMES_REV: Record = { + 1: "January", 2: "February", 3: "March", 4: "April", 5: "May", 6: "June", + 7: "July", 8: "August", 9: "September", 10: "October", 11: "November", 12: "December", +}; + +const DOW_NAMES: Record = { + SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6, +}; + +const DOW_NAMES_REV: Record = { + 0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday", +}; + +const FIELDS: FieldDefinition[] = [ + { + name: "Minute", + key: "minute", + min: 0, + max: 59, + description: "Minutes past the hour (0 - 59)", + }, + { + name: "Hour", + key: "hour", + min: 0, + max: 23, + description: "Hours of the day (0 - 23, 24-hour clock)", + }, + { + name: "Day of Month", + key: "dom", + min: 1, + max: 31, + description: "Days of the month (1 - 31)", + }, + { + name: "Month", + key: "month", + min: 1, + max: 12, + namesMap: MONTH_NAMES, + reverseNamesMap: MONTH_NAMES_REV, + description: "Months of the year (1 - 12 or JAN - DEC)", + }, + { + name: "Day of Week", + key: "dow", + min: 0, + max: 7, + namesMap: DOW_NAMES, + reverseNamesMap: DOW_NAMES_REV, + description: "Days of the week (0 - 7 or SUN - SAT, 0 & 7 = Sunday)", + }, +]; + +interface ParsedField { + raw: string; + fieldDef: FieldDefinition; + valid: boolean; + error?: string; + values: number[]; + isWildcard: boolean; + isStep: boolean; + explanation: string; +} + +interface ParsedCron { + valid: boolean; + error?: string; + fields: ParsedField[]; + humanExplanation: string; +} + +// Helper to format hours in 12-hour AM/PM format +function formatHour12(h: number): string { + const period = h >= 12 ? "PM" : "AM"; + const hour12 = h % 12 === 0 ? 12 : h % 12; + return `${hour12}:00 ${period}`; +} + +// Helper to format full time in 12-hour format +function formatTime12(h: number, m: number): string { + const period = h >= 12 ? "PM" : "AM"; + const hour12 = h % 12 === 0 ? 12 : h % 12; + const padM = m.toString().padStart(2, "0"); + return `${hour12}:${padM} ${period}`; +} + +// Helper for relative time description +function getRelativeTime(targetDate: Date, now: Date): string { + const diffMs = targetDate.getTime() - now.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec <= 0) return "just now"; + if (diffSec < 60) return `in ${diffSec}s`; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `in ${diffMin} min${diffMin > 1 ? "s" : ""}`; + + const diffHours = Math.floor(diffMin / 60); + const remainingMins = diffMin % 60; + if (diffHours < 24) { + return `in ${diffHours}h ${remainingMins > 0 ? `${remainingMins}m` : ""}`; + } + + const diffDays = Math.floor(diffHours / 24); + const remainingHours = diffHours % 24; + if (diffDays === 1) return `tomorrow in ${diffHours}h`; + if (diffDays < 30) return `in ${diffDays} days ${remainingHours > 0 ? `${remainingHours}h` : ""}`; + + const diffMonths = Math.floor(diffDays / 30); + return `in ${diffMonths} month${diffMonths > 1 ? "s" : ""}`; +} + +// Parse a single cron field token +function parseCronField(raw: string, fieldDef: FieldDefinition): ParsedField { + const trimmed = raw.trim(); + if (!trimmed) { + return { + raw, + fieldDef, + valid: false, + error: `Field "${fieldDef.name}" cannot be empty`, + values: [], + isWildcard: false, + isStep: false, + explanation: "", + }; + } + + const isWildcard = trimmed === "*"; + const isStep = trimmed.includes("/"); + const valuesSet = new Set(); + + const convertTokenToNum = (tok: string): number | null => { + const upper = tok.trim().toUpperCase(); + if (fieldDef.namesMap && fieldDef.namesMap[upper] !== undefined) { + return fieldDef.namesMap[upper]; + } + const n = Number(tok); + if (!isNaN(n) && Number.isInteger(n)) { + return n; + } + return null; + }; + + const parts = trimmed.split(","); + + for (const part of parts) { + const subParts = part.split("/"); + + if (subParts.length > 2) { + return { + raw, + fieldDef, + valid: false, + error: `Invalid step expression "${part}" in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + let rangeStart = fieldDef.min; + let rangeEnd = fieldDef.max; + let step = 1; + + if (subParts.length === 2) { + const stepNum = Number(subParts[1]); + if (isNaN(stepNum) || stepNum < 1 || !Number.isInteger(stepNum)) { + return { + raw, + fieldDef, + valid: false, + error: `Invalid step value "${subParts[1]}" in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + step = stepNum; + } + + const basePart = subParts[0].trim(); + + if (basePart === "*") { + rangeStart = fieldDef.min; + rangeEnd = fieldDef.max; + } else if (basePart.includes("-")) { + const rangeParts = basePart.split("-"); + if (rangeParts.length !== 2) { + return { + raw, + fieldDef, + valid: false, + error: `Invalid range "${basePart}" in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + const startNum = convertTokenToNum(rangeParts[0]); + const endNum = convertTokenToNum(rangeParts[1]); + + if (startNum === null || endNum === null) { + return { + raw, + fieldDef, + valid: false, + error: `Invalid range bounds "${basePart}" in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + if (startNum < fieldDef.min || endNum > fieldDef.max) { + return { + raw, + fieldDef, + valid: false, + error: `Range ${startNum}-${endNum} out of bounds (${fieldDef.min}-${fieldDef.max}) in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + if (startNum > endNum) { + return { + raw, + fieldDef, + valid: false, + error: `Range start ${startNum} is greater than end ${endNum} in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + rangeStart = startNum; + rangeEnd = endNum; + } else { + const singleNum = convertTokenToNum(basePart); + if (singleNum === null) { + return { + raw, + fieldDef, + valid: false, + error: `Invalid token "${basePart}" in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + if (singleNum < fieldDef.min || singleNum > fieldDef.max) { + return { + raw, + fieldDef, + valid: false, + error: `Value ${singleNum} out of allowed range (${fieldDef.min}-${fieldDef.max}) in ${fieldDef.name}`, + values: [], + isWildcard, + isStep, + explanation: "", + }; + } + + if (subParts.length === 2) { + rangeStart = singleNum; + rangeEnd = fieldDef.max; + } else { + rangeStart = singleNum; + rangeEnd = singleNum; + } + } + + for (let i = rangeStart; i <= rangeEnd; i += step) { + let val = i; + // In Day of Week, 7 is also Sunday (0) + if (fieldDef.key === "dow" && val === 7) { + val = 0; + } + valuesSet.add(val); + } + } + + const values = Array.from(valuesSet).sort((a, b) => a - b); + + // Build field-level explanation + let explanation = ""; + if (isWildcard) { + explanation = `Every ${fieldDef.name.toLowerCase()}`; + } else if (trimmed.startsWith("*/")) { + const stepVal = trimmed.substring(2); + explanation = `Every ${stepVal} ${fieldDef.name.toLowerCase()}s`; + } else if (fieldDef.key === "dow") { + const dayNames = values.map((v) => DOW_NAMES_REV[v] || v.toString()); + if (values.length === 5 && values.every((v, i) => v === i + 1)) { + explanation = "Monday through Friday (weekdays)"; + } else if (values.length === 2 && values.includes(0) && values.includes(6)) { + explanation = "Saturday and Sunday (weekends)"; + } else { + explanation = `On ${dayNames.join(", ")}`; + } + } else if (fieldDef.key === "month") { + const monthNames = values.map((v) => MONTH_NAMES_REV[v] || `Month ${v}`); + explanation = `In ${monthNames.join(", ")}`; + } else if (fieldDef.key === "hour") { + if (values.length === 1) { + explanation = `At ${formatHour12(values[0])} (${values[0].toString().padStart(2, "0")}:00)`; + } else { + explanation = `At hours ${values.map((v) => v.toString().padStart(2, "0")).join(", ")}`; + } + } else if (fieldDef.key === "minute") { + if (values.length === 1) { + explanation = `At minute ${values[0]}`; + } else { + explanation = `At minutes ${values.join(", ")}`; + } + } else if (fieldDef.key === "dom") { + if (values.length === 1) { + explanation = `On day ${values[0]} of the month`; + } else { + explanation = `On days ${values.join(", ")} of the month`; + } + } + + return { + raw, + fieldDef, + valid: true, + values, + isWildcard, + isStep, + explanation, + }; +} + +// Generate overall human-readable explanation +function generateCronExplanation(fields: ParsedField[]): string { + if (fields.some((f) => !f.valid)) return "Invalid cron expression"; + + const [minF, hourF, domF, monthF, dowF] = fields; + + const minRaw = minF.raw.trim(); + const hourRaw = hourF.raw.trim(); + const domRaw = domF.raw.trim(); + const monthRaw = monthF.raw.trim(); + const dowRaw = dowF.raw.trim(); + + // 1. Every minute (* * * * *) + if (minRaw === "*" && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") { + return "At every minute of every day"; + } + + // 2. Every N minutes (*/5 * * * *) + if (minRaw.startsWith("*/") && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") { + const step = minRaw.replace("*/", ""); + return `Every ${step} minutes`; + } + + // 3. Every hour on minute 0 (0 * * * *) + if (minRaw === "0" && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") { + return "At the start of every hour (minute 0)"; + } + + // 4. Every N hours (0 */2 * * *) + if (minRaw === "0" && hourRaw.startsWith("*/") && domRaw === "*" && monthRaw === "*" && dowRaw === "*") { + const step = hourRaw.replace("*/", ""); + return `Every ${step} hours, at minute 0`; + } + + // Build natural description + const timeDesc: string[] = []; + + if (minF.values.length === 1 && hourF.values.length === 1) { + const h = hourF.values[0]; + const m = minF.values[0]; + timeDesc.push(`At ${formatTime12(h, m)} (${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")})`); + } else if (minF.values.length === 1 && hourF.isWildcard) { + timeDesc.push(`At minute ${minF.values[0]} of every hour`); + } else if (minRaw.startsWith("*/") && hourF.values.length === 1) { + const step = minRaw.replace("*/", ""); + timeDesc.push(`Every ${step} minutes, during hour ${hourF.values[0]}`); + } else if (minRaw.startsWith("*/") && hourF.isWildcard) { + const step = minRaw.replace("*/", ""); + timeDesc.push(`Every ${step} minutes`); + } else { + timeDesc.push(`${minF.explanation}, ${hourF.explanation.toLowerCase()}`); + } + + const dateDesc: string[] = []; + + // Day of Month & Day of Week + if (!domF.isWildcard && dowF.isWildcard) { + dateDesc.push(`on day ${domF.values.join(", ")} of the month`); + } else if (domF.isWildcard && !dowF.isWildcard) { + if (dowF.values.length === 5 && [1, 2, 3, 4, 5].every((v) => dowF.values.includes(v))) { + dateDesc.push("Monday through Friday (weekdays)"); + } else if (dowF.values.length === 2 && dowF.values.includes(0) && dowF.values.includes(6)) { + dateDesc.push("on weekends (Saturday and Sunday)"); + } else { + const days = dowF.values.map((v) => DOW_NAMES_REV[v] || v.toString()); + dateDesc.push(`every ${days.join(", ")}`); + } + } else if (!domF.isWildcard && !dowF.isWildcard) { + const days = dowF.values.map((v) => DOW_NAMES_REV[v] || v.toString()); + dateDesc.push(`on day ${domF.values.join(", ")} of the month and on ${days.join(", ")}`); + } + + // Month + if (!monthF.isWildcard) { + const months = monthF.values.map((v) => MONTH_NAMES_REV[v] || `Month ${v}`); + dateDesc.push(`in ${months.join(", ")}`); + } + + if (dateDesc.length === 0) { + return `${timeDesc.join(", ")}, every day`; + } + + return `${timeDesc.join(", ")}, ${dateDesc.join(", ")}`; +} + +// Calculate next scheduled run times (up to count) +function computeNextRuns(fields: ParsedField[], count: number = 10, timezone: "local" | "utc" = "local"): Date[] { + if (fields.some((f) => !f.valid)) return []; + + const [minF, hourF, domF, monthF, dowF] = fields; + const minSet = new Set(minF.values); + const hourSet = new Set(hourF.values); + const domSet = new Set(domF.values); + const monthSet = new Set(monthF.values); + const dowSet = new Set(dowF.values); + + const results: Date[] = []; + const start = new Date(); + + // Start from next minute + const current = new Date(start.getTime()); + current.setSeconds(0, 0); + current.setMinutes(current.getMinutes() + 1); + + // Safety limit to avoid infinite loops (max 5 years or 500,000 steps) + let steps = 0; + const maxSteps = 500000; + + while (results.length < count && steps < maxSteps) { + steps++; + + const getYear = timezone === "utc" ? current.getUTCFullYear() : current.getFullYear(); + const getMonth = (timezone === "utc" ? current.getUTCMonth() : current.getMonth()) + 1; + const getDate = timezone === "utc" ? current.getUTCDate() : current.getDate(); + const getDay = timezone === "utc" ? current.getUTCDay() : current.getDay(); + const getHour = timezone === "utc" ? current.getUTCHours() : current.getHours(); + const getMin = timezone === "utc" ? current.getUTCMinutes() : current.getMinutes(); + + // Check Month + if (!monthSet.has(getMonth)) { + // Advance to start of next month + if (timezone === "utc") { + current.setUTCMonth(current.getUTCMonth() + 1, 1); + current.setUTCHours(0, 0, 0, 0); + } else { + current.setMonth(current.getMonth() + 1, 1); + current.setHours(0, 0, 0, 0); + } + continue; + } + + // Check Day of Month & Day of Week + const domMatch = domSet.has(getDate); + const dowMatch = dowSet.has(getDay); + + let dayMatch = false; + if (domF.isWildcard && dowF.isWildcard) { + dayMatch = true; + } else if (!domF.isWildcard && dowF.isWildcard) { + dayMatch = domMatch; + } else if (domF.isWildcard && !dowF.isWildcard) { + dayMatch = dowMatch; + } else { + // Both specified: standard cron OR matching + dayMatch = domMatch || dowMatch; + } + + if (!dayMatch) { + // Advance to next day + if (timezone === "utc") { + current.setUTCDate(current.getUTCDate() + 1); + current.setUTCHours(0, 0, 0, 0); + } else { + current.setDate(current.getDate() + 1); + current.setHours(0, 0, 0, 0); + } + continue; + } + + // Check Hour + if (!hourSet.has(getHour)) { + // Advance to next hour + if (timezone === "utc") { + current.setUTCHours(current.getUTCHours() + 1, 0, 0, 0); + } else { + current.setHours(current.getHours() + 1, 0, 0, 0); + } + continue; + } + + // Check Minute + if (minSet.has(getMin)) { + results.push(new Date(current.getTime())); + } + + // Advance 1 minute + if (timezone === "utc") { + current.setUTCMinutes(current.getUTCMinutes() + 1); + } else { + current.setMinutes(current.getMinutes() + 1); + } + } + + return results; +} + +const PRESETS = [ + { name: "Every minute", cron: "* * * * *", category: "Frequent" }, + { name: "Every 5 minutes", cron: "*/5 * * * *", category: "Frequent" }, + { name: "Every 15 minutes", cron: "*/15 * * * *", category: "Frequent" }, + { name: "Every 30 minutes", cron: "*/30 * * * *", category: "Frequent" }, + { name: "Every hour", cron: "0 * * * *", category: "Hourly" }, + { name: "Every 2 hours", cron: "0 */2 * * *", category: "Hourly" }, + { name: "Every 6 hours", cron: "0 */6 * * *", category: "Hourly" }, + { name: "Every day at midnight", cron: "0 0 * * *", category: "Daily" }, + { name: "Every day at 9:00 AM", cron: "0 9 * * *", category: "Daily" }, + { name: "Every weekday at 9:00 AM", cron: "0 9 * * 1-5", category: "Weekly" }, + { name: "Every Monday at midnight", cron: "0 0 * * 1", category: "Weekly" }, + { name: "Every Sunday at midnight", cron: "0 0 * * 0", category: "Weekly" }, + { name: "Twice daily (12am & 12pm)", cron: "0 0,12 * * *", category: "Daily" }, + { name: "1st of every month (midnight)", cron: "0 0 1 * *", category: "Monthly" }, + { name: "1st & 15th of every month", cron: "0 0 1,15 * *", category: "Monthly" }, + { name: "Quarterly (Jan, Apr, Jul, Oct 1st)", cron: "0 0 1 1,4,7,10 *", category: "Monthly" }, +]; + +export default function CronParserPage() { + const [cronExpression, setCronExpression] = useState("*/5 * * * *"); + const [runCount, setRunCount] = useState(8); + const [timezone, setTimezone] = useState<"local" | "utc">("local"); + const [copiedCron, setCopiedCron] = useState(false); + const [copiedSchedule, setCopiedSchedule] = useState(false); + + // Parse 5-part cron expression + const parsedCron = useMemo(() => { + const rawTrimmed = cronExpression.trim(); + if (!rawTrimmed) { + return { + valid: false, + error: "Please enter a 5-part cron expression", + fields: [], + humanExplanation: "", + }; + } + + const tokens = rawTrimmed.split(/\s+/); + if (tokens.length !== 5) { + return { + valid: false, + error: `Expected 5 space-separated parts (Minute Hour Day-of-Month Month Day-of-Week), found ${tokens.length}`, + fields: [], + humanExplanation: "", + }; + } + + const fields = tokens.map((token, index) => { + return parseCronField(token, FIELDS[index]); + }); + + const invalidField = fields.find((f) => !f.valid); + if (invalidField) { + return { + valid: false, + error: invalidField.error || "Invalid cron field", + fields, + humanExplanation: "", + }; + } + + const humanExplanation = generateCronExplanation(fields); + + return { + valid: true, + fields, + humanExplanation, + }; + }, [cronExpression]); + + // Compute next runs + const nextRuns = useMemo(() => { + if (!parsedCron.valid || parsedCron.fields.length !== 5) return []; + return computeNextRuns(parsedCron.fields, runCount, timezone); + }, [parsedCron, runCount, timezone]); + + // Copy Cron String + const handleCopyCron = async () => { + try { + await navigator.clipboard.writeText(cronExpression.trim()); + setCopiedCron(true); + setTimeout(() => setCopiedCron(false), 2000); + } catch { + // Fallback + } + }; + + // Copy Next Run Times + const handleCopySchedule = async () => { + if (nextRuns.length === 0) return; + const text = nextRuns + .map((d, i) => `${i + 1}. ${timezone === "utc" ? d.toUTCString() : d.toString()}`) + .join("\n"); + try { + await navigator.clipboard.writeText(text); + setCopiedSchedule(true); + setTimeout(() => setCopiedSchedule(false), 2000); + } catch { + // Fallback + } + }; + + // Handle single field input update + const handleFieldChange = useCallback( + (index: number, val: string) => { + const tokens = cronExpression.trim().split(/\s+/); + while (tokens.length < 5) tokens.push("*"); + tokens[index] = val || "*"; + setCronExpression(tokens.join(" ")); + }, + [cronExpression] + ); + + const now = useMemo(() => new Date(), []); + + return ( + + Cron Expression Parser Online — DevToolbox + + +
+ {/* Top Preset Gallery */} +
+
+ + Common Cron Presets + + Click any preset to load +
+ +
+ {PRESETS.map((p) => { + const isSelected = cronExpression.trim() === p.cron; + return ( + + ); + })} +
+
+ + {/* Main Cron Input Bar */} +
+
+ +
+ Minute Hour Day Month Weekday + +
+
+ +
+ setCronExpression(e.target.value)} + placeholder="* * * * *" + className="w-full p-4 font-mono text-xl sm:text-2xl tracking-wider rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:outline-none shadow-inner" + /> +
+ + {/* Human Readable Explanation Banner */} + {parsedCron.valid ? ( +
+ + ✓ + +
+ + Human-Readable Schedule + +

+ “{parsedCron.humanExplanation}” +

+
+
+ ) : ( +
+ + ! + +
+ + Syntax Error + +

{parsedCron.error}

+
+
+ )} +
+ + {/* 5 Field Breakdown Interactive Cards */} +
+
+

+ Cron Field Breakdown & Structure +

+ Edit individual fields directly below +
+ +
+ {FIELDS.map((fDef, idx) => { + const parsedF = parsedCron.fields[idx]; + const rawToken = parsedF ? parsedF.raw : ""; + const isValid = parsedF ? parsedF.valid : true; + + return ( +
+
+
+ + {fDef.name} + + + {fDef.min}-{fDef.max} + +
+ + handleFieldChange(idx, e.target.value)} + placeholder="*" + className="w-full p-2 font-mono text-base font-bold text-center rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none" + /> + +

+ {fDef.description} +

+
+ + {parsedF && parsedF.valid && ( +
+ + {parsedF.explanation} + +
+ Matches {parsedF.values.length} {parsedF.values.length === 1 ? "value" : "values"} +
+
+ )} +
+ ); + })} +
+
+ + {/* Scheduled Runs Section */} +
+
+
+ + ⏰ + +

+ Upcoming Scheduled Runs (Next {runCount}) +

+
+ +
+ {/* Timezone Toggle */} +
+ Timezone: +
+ + +
+
+ + {/* Count selector */} +
+ Count: + +
+ + +
+
+ + {nextRuns.length > 0 ? ( +
+ {nextRuns.map((date, idx) => { + const relative = getRelativeTime(date, now); + const isLocal = timezone === "local"; + const dateStr = isLocal + ? date.toLocaleDateString("en-US", { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }) + : date.toUTCString().slice(0, 16); + + const timeStr = isLocal + ? date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: true, + }) + : date.toUTCString().slice(17, 25) + " UTC"; + + return ( +
+
+ + {idx + 1} + +
+ + {dateStr} + + + {timeStr} + +
+
+ +
+ + {relative} + + + {date.toISOString()} + +
+
+ ); + })} +
+ ) : ( +
+ No upcoming runs calculated. Please check your cron expression. +
+ )} +
+ + {/* Cron Syntax Cheat Sheet */} +
+

+ Cron Syntax Reference & Special Characters +

+ +
+
+
* (Asterisk)
+

Wildcard (Any Value)

+

+ Matches every possible value in the field. E.g. * in hour means every hour. +

+
+ +
+
, (Comma)
+

Value List

+

+ Specifies discrete multiple values. E.g. 1,15,30 in minute runs at minutes 1, 15, 30. +

+
+ +
+
- (Hyphen)
+

Range of Values

+

+ Specifies an inclusive range. E.g. 1-5 in weekday means Monday through Friday. +

+
+ +
+
/ (Slash)
+

Step Values

+

+ Specifies step increments. E.g. */15 in minute runs every 15 minutes. +

+
+
+
+
+
+ ); +} diff --git a/src/app/tools/csv-to-json/page.tsx b/src/app/tools/csv-to-json/page.tsx new file mode 100644 index 0000000..f74e6d5 --- /dev/null +++ b/src/app/tools/csv-to-json/page.tsx @@ -0,0 +1,611 @@ +"use client"; + +import { useState, useCallback, useId, useTransition } from "react"; +import ToolLayout from "@/components/ToolLayout"; + +interface StatusInfo { + type: "idle" | "success" | "error"; + message?: string; + rowCount?: number; + colCount?: number; + byteSize?: number; + warning?: string; +} + +const SAMPLE_CSV = `id,name,email,role,salary,active,address.city,address.state,skills +101,"Alex Johnson",alex.johnson@example.com,"Senior Engineer",125000,true,"San Francisco",CA,"[""TypeScript"",""React"",""Node.js""]" +102,"Sarah ""Dev"" Connor",sarah.c@example.com,"Product Designer",110000,true,Austin,TX,"[""Figma"",""UI/UX""]" +103,"Liam O'Connor +Remote Lead",liam.oc@example.com,"DevOps Engineer",130000,false,Seattle,WA,"[""Kubernetes"",""Docker"",""AWS""]"`; + +function detectDelimiter(text: string): string { + const sample = text.slice(0, 2000); + const candidates = [",", ";", "\t", "|"]; + const counts: Record = { ",": 0, ";": 0, "\t": 0, "|": 0 }; + + let inQuotes = false; + for (let i = 0; i < sample.length; i++) { + const char = sample[i]; + if (char === '"') { + inQuotes = !inQuotes; + } else if (!inQuotes && counts[char] !== undefined) { + counts[char]++; + } + } + + let bestDelimiter = ","; + let maxCount = -1; + for (const d of candidates) { + if (counts[d] > maxCount && counts[d] > 0) { + maxCount = counts[d]; + bestDelimiter = d; + } + } + + return bestDelimiter; +} + +function parseCSV(text: string, delimiter: string): string[][] { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentField = ""; + let inQuotes = false; + let i = 0; + + while (i < text.length) { + const char = text[i]; + const nextChar = text[i + 1]; + + if (inQuotes) { + if (char === '"' && nextChar === '"') { + currentField += '"'; + i += 2; + } else if (char === '"') { + inQuotes = false; + i++; + } else { + currentField += char; + i++; + } + } else { + if (char === '"') { + inQuotes = true; + i++; + } else if (text.slice(i, i + delimiter.length) === delimiter) { + currentRow.push(currentField); + currentField = ""; + i += delimiter.length; + } else if (char === "\r" && nextChar === "\n") { + currentRow.push(currentField); + rows.push(currentRow); + currentRow = []; + currentField = ""; + i += 2; + } else if (char === "\n" || char === "\r") { + currentRow.push(currentField); + rows.push(currentRow); + currentRow = []; + currentField = ""; + i++; + } else { + currentField += char; + i++; + } + } + } + + if (currentField.length > 0 || currentRow.length > 0) { + currentRow.push(currentField); + rows.push(currentRow); + } + + // Remove empty trailing lines + while (rows.length > 0 && rows[rows.length - 1].length === 1 && rows[rows.length - 1][0].trim() === "") { + rows.pop(); + } + + return rows; +} + +function unflattenObject(data: Record): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const parts = key.split("."); + let current: Record = result; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + current[part] = value; + } else { + if (!current[part] || typeof current[part] !== "object" || Array.isArray(current[part])) { + current[part] = {}; + } + current = current[part] as Record; + } + } + } + + return result; +} + +function parseCellValue(value: string, autoParseTypes: boolean): unknown { + if (!autoParseTypes) return value; + const trimmed = value.trim(); + if (trimmed === "") return ""; + if (trimmed.toLowerCase() === "null") return null; + if (trimmed.toLowerCase() === "true") return true; + if (trimmed.toLowerCase() === "false") return false; + + // Numeric check (avoid converting leading zeros like phone numbers / zip codes "01234" to numbers) + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + if (trimmed === "0" || !trimmed.startsWith("0") || trimmed.startsWith("0.")) { + const num = Number(trimmed); + if (!isNaN(num)) { + return num; + } + } + } + + // Check if cell is serialized JSON (e.g. nested array or object) + if ((trimmed.startsWith("[") && trimmed.endsWith("]")) || (trimmed.startsWith("{") && trimmed.endsWith("}"))) { + try { + return JSON.parse(trimmed); + } catch { + // fallback to string + } + } + + return value; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +export default function CsvToJsonPage() { + const [input, setInput] = useState(""); + const [output, setOutput] = useState(""); + const [delimiterSetting, setDelimiterSetting] = useState("auto"); + const [hasHeaders, setHasHeaders] = useState(true); + const [autoParseTypes, setAutoParseTypes] = useState(true); + const [unflatten, setUnflatten] = useState(true); + const [indent, setIndent] = useState("2"); + const [status, setStatus] = useState({ type: "idle" }); + const [copied, setCopied] = useState(false); + const [, startTransition] = useTransition(); + + const delimiterSelectId = useId(); + const indentSelectId = useId(); + + const convertCsvToJson = useCallback( + ( + csvText: string, + delimOpt: string, + headersOpt: boolean, + typesOpt: boolean, + unflattenOpt: boolean, + indentOpt: string + ) => { + if (!csvText.trim()) { + setOutput(""); + setStatus({ type: "idle" }); + return; + } + + try { + const activeDelimiter = delimOpt === "auto" ? detectDelimiter(csvText) : delimOpt; + const parsedRows = parseCSV(csvText, activeDelimiter); + + if (parsedRows.length === 0) { + setOutput("[]"); + setStatus({ + type: "success", + message: "Parsed 0 rows.", + rowCount: 0, + colCount: 0, + byteSize: 2, + }); + return; + } + + let jsonResult: unknown; + let columnCount = 0; + let recordCount = 0; + + if (headersOpt) { + const headerRow = parsedRows[0]; + columnCount = headerRow.length; + const dataRows = parsedRows.slice(1); + recordCount = dataRows.length; + + const records: Record[] = []; + for (const row of dataRows) { + const record: Record = {}; + for (let c = 0; c < headerRow.length; c++) { + const header = headerRow[c]?.trim() || `column_${c + 1}`; + const val = c < row.length ? row[c] : ""; + record[header] = parseCellValue(val, typesOpt); + } + + if (unflattenOpt) { + records.push(unflattenObject(record)); + } else { + records.push(record); + } + } + jsonResult = records; + } else { + // No headers: array of arrays or array of objects with generic column keys + recordCount = parsedRows.length; + columnCount = Math.max(...parsedRows.map((r) => r.length), 0); + jsonResult = parsedRows.map((row) => row.map((cell) => parseCellValue(cell, typesOpt))); + } + + const indentVal = indentOpt === "tab" ? "\t" : parseInt(indentOpt, 10); + const formattedJson = JSON.stringify(jsonResult, null, indentVal); + setOutput(formattedJson); + + const byteSize = new Blob([formattedJson]).size; + setStatus({ + type: "success", + message: `Successfully converted ${recordCount} record${recordCount === 1 ? "" : "s"} (${columnCount} columns)!`, + rowCount: recordCount, + colCount: columnCount, + byteSize, + }); + } catch (err) { + setOutput(""); + setStatus({ + type: "error", + message: err instanceof Error ? err.message : "Failed to parse CSV data.", + }); + } + }, + [] + ); + + const handleConvert = useCallback(() => { + startTransition(() => { + convertCsvToJson(input, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent); + }); + }, [input, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent, convertCsvToJson]); + + const handleLoadSample = useCallback(() => { + setInput(SAMPLE_CSV); + startTransition(() => { + convertCsvToJson(SAMPLE_CSV, delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent); + }); + }, [delimiterSetting, hasHeaders, autoParseTypes, unflatten, indent, convertCsvToJson]); + + const handleClear = useCallback(() => { + setInput(""); + setOutput(""); + setStatus({ type: "idle" }); + }, []); + + const handleCopy = useCallback(async () => { + if (!output) return; + try { + await navigator.clipboard.writeText(output); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = output; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [output]); + + const handleDownload = useCallback(() => { + if (!output) return; + const blob = new Blob([output], { type: "application/json;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "data.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [output]); + + const inputLineCount = input ? input.split("\n").length : 0; + const outputLineCount = output ? output.split("\n").length : 0; + + return ( + +
+ {/* Top Controls Toolbar */} +
+
+ + + + + +
+ + {/* Options */} +
+ {/* Delimiter */} +
+ + +
+ + {/* Indent Selector */} +
+ + +
+
+
+ + {/* Toggles Strip */} +
+ + + + + +
+ + {/* Status Notification Banner */} + {status.type === "success" && ( +
+
+ + + + {status.message} +
+
+ {status.rowCount !== undefined && Rows: {status.rowCount}} + {status.colCount !== undefined && Columns: {status.colCount}} + {status.byteSize !== undefined && Size: {formatBytes(status.byteSize)}} +
+
+ )} + + {status.type === "error" && ( +
+
+ + + +
+

Error Parsing CSV

+

{status.message}

+
+
+
+ )} + + {/* Input & Output Split Editor */} +
+ {/* Input Area */} +
+
+
+ Input CSV + + ({inputLineCount} {inputLineCount === 1 ? "line" : "lines"}, {input.length} chars) + +
+
+ + +
+
+