From d90a980dc43264e98cfe118cdad0571e634d3427 Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:07:37 +0530 Subject: [PATCH 01/24] feat. add crontab explainer --- .../crontabExplainer.tsx | 374 ++++++++++++++++++ app/libs/constants.tsx | 11 + app/libs/developmentToolsConstant.tsx | 82 ++++ 3 files changed, 467 insertions(+) create mode 100644 app/components/developmentToolsComponent/crontabExplainer.tsx diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx new file mode 100644 index 0000000..0cceffc --- /dev/null +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -0,0 +1,374 @@ +"use client"; +import React, { useMemo, useState } from "react"; + +const Cmd = ({ children }: { children: string }) => ( + {children} +); + +// Cron aliases mapping +const ALIASES: Record = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +}; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +// Parse a cron field into human-readable text +const parseField = (field: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { + if (field === "*") { + if (type === "minute") return "every minute"; + if (type === "hour") return "every hour"; + if (type === "dom") return "every day"; + if (type === "month") return "every month"; + return "every day of week"; + } + + // Handle step values (*/n or x/n) + if (field.includes("/")) { + const [base, step] = field.split("/"); + const stepNum = parseInt(step); + if (base === "*") { + if (type === "minute") return `every ${stepNum} minute${stepNum > 1 ? "s" : ""}`; + if (type === "hour") return `every ${stepNum} hour${stepNum > 1 ? "s" : ""}`; + if (type === "dom") return `every ${stepNum} day${stepNum > 1 ? "s" : ""}`; + if (type === "month") return `every ${stepNum} month${stepNum > 1 ? "s" : ""}`; + return `every ${stepNum} day${stepNum > 1 ? "s" : ""} of week`; + } + return `every ${stepNum} starting from ${base}`; + } + + // Handle ranges (x-y) + if (field.includes("-") && !field.includes(",")) { + const [start, end] = field.split("-"); + const startLabel = formatValue(start, type); + const endLabel = formatValue(end, type); + return `from ${startLabel} to ${endLabel}`; + } + + // Handle lists (x,y,z) + if (field.includes(",")) { + const values = field.split(",").map((v) => formatValue(v.trim(), type)); + if (values.length > 3) { + return `at ${values.slice(0, 3).join(", ")}, and ${values.length - 3} more`; + } + return `at ${values.join(", ")}`; + } + + // Single value + return `at ${formatValue(field, type)}`; +}; + +const formatValue = (value: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { + const num = parseInt(value); + if (isNaN(num)) { + // Handle month/day names + if (type === "month") { + const monthIndex = MONTHS.findIndex((m) => m.toLowerCase() === value.toLowerCase()); + return monthIndex >= 0 ? MONTHS[monthIndex] : value; + } + if (type === "dow") { + const dowIndex = DOW.findIndex((d) => d.toLowerCase() === value.toLowerCase()); + return dowIndex >= 0 ? DOW[dowIndex] : value; + } + return value; + } + + if (type === "month" && num >= 1 && num <= 12) return MONTHS[num - 1]; + if (type === "dow" && num >= 0 && num <= 6) return DOW[num]; + if (type === "hour") return `${num.toString().padStart(2, "0")}:00`; + if (type === "minute") return `minute ${num}`; + return value; +}; + +// Generate human-readable explanation +const explainCron = (cronExpr: string): string => { + const trimmed = cronExpr.trim().toLowerCase(); + + // Check for aliases + if (ALIASES[trimmed]) { + const aliasName = trimmed.substring(1); // Remove @ + return `Runs ${aliasName} (${ALIASES[trimmed]})`; + } + + const parts = cronExpr.trim().split(/\s+/); + if (parts.length !== 5) { + return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; + } + + const [minute, hour, dom, month, dow] = parts; + + const minuteText = parseField(minute, "minute"); + const hourText = parseField(hour, "hour"); + const domText = parseField(dom, "dom"); + const monthText = parseField(month, "month"); + const dowText = parseField(dow, "dow"); + + // Build natural sentence + let explanation = "Runs "; + + // Time part + if (minute === "*" && hour === "*") { + explanation += "every minute"; + } else if (minute !== "*" && hour === "*") { + explanation += `${minuteText} of every hour`; + } else if (minute === "*" && hour !== "*") { + explanation += `every minute ${hourText}`; + } else { + explanation += `at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; + } + + // Day constraints + const hasDom = dom !== "*"; + const hasDow = dow !== "*"; + + if (hasDom && hasDow) { + explanation += `, ${domText} of the month OR ${dowText}`; + } else if (hasDom) { + explanation += `, ${domText} of the month`; + } else if (hasDow) { + explanation += `, ${dowText}`; + } + + // Month constraint + if (month !== "*") { + explanation += `, ${monthText}`; + } + + return explanation; +}; + +// Calculate next N executions +const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { + try { + const trimmed = cronExpr.trim().toLowerCase(); + const expr = ALIASES[trimmed] || cronExpr.trim(); + + const parts = expr.split(/\s+/); + if (parts.length !== 5) return []; + + const [minuteField, hourField, domField, monthField, dowField] = parts; + + const now = new Date(); + const executions: string[] = []; + let current = new Date(now); + current.setSeconds(0); + current.setMilliseconds(0); + + // Simple implementation - check next 10000 minutes + for (let i = 0; i < 10000 && executions.length < count; i++) { + current = new Date(current.getTime() + 60000); // Add 1 minute + + const minute = current.getMinutes(); + const hour = current.getHours(); + const dom = current.getDate(); + const month = current.getMonth() + 1; + const dow = current.getDay(); + + if ( + matchField(minute, minuteField, 0, 59) && + matchField(hour, hourField, 0, 23) && + matchField(dom, domField, 1, 31) && + matchField(month, monthField, 1, 12) && + matchField(dow, dowField, 0, 6) + ) { + executions.push(current.toLocaleString()); + } + } + + return executions; + } catch { + return []; + } +}; + +const matchField = (value: number, field: string, min: number, max: number): boolean => { + if (field === "*") return true; + + // Handle step values + if (field.includes("/")) { + const [base, step] = field.split("/"); + const stepNum = parseInt(step); + if (base === "*") { + return value % stepNum === 0; + } + const baseNum = parseInt(base); + return value >= baseNum && (value - baseNum) % stepNum === 0; + } + + // Handle ranges + if (field.includes("-") && !field.includes(",")) { + const [start, end] = field.split("-").map((v) => parseInt(v)); + return value >= start && value <= end; + } + + // Handle lists + if (field.includes(",")) { + const values = field.split(",").map((v) => parseInt(v.trim())); + return values.includes(value); + } + + // Single value + return value === parseInt(field); +}; + +const CrontabExplainer = () => { + const [cronInput, setCronInput] = useState(""); + const [showExecutions, setShowExecutions] = useState(true); + + const explanation = useMemo(() => { + if (!cronInput.trim()) return ""; + return explainCron(cronInput); + }, [cronInput]); + + const nextExecutions = useMemo(() => { + if (!cronInput.trim() || !showExecutions) return []; + return getNextExecutions(cronInput, 5); + }, [cronInput, showExecutions]); + + const isValid = useMemo(() => { + if (!cronInput.trim()) return true; + const trimmed = cronInput.trim().toLowerCase(); + if (ALIASES[trimmed]) return true; + const parts = cronInput.trim().split(/\s+/); + return parts.length === 5; + }, [cronInput]); + + const examples = [ + { label: "Every 5 minutes", cron: "*/5 * * * *" }, + { label: "Daily at 2:30 AM", cron: "30 2 * * *" }, + { label: "Weekdays at 9 AM", cron: "0 9 * * 1-5" }, + { label: "First day of month", cron: "0 0 1 * *" }, + { label: "@hourly", cron: "@hourly" }, + { label: "@daily", cron: "@daily" }, + ]; + + const handleCopy = async () => { + if (!explanation) return; + try { + await navigator.clipboard.writeText(explanation); + } catch {} + }; + + return ( +
+
+
+
+ {/* Input Section */} +
+
+ +
+ Format: minute hour day month weekday +
+
+ setCronInput(e.target.value)} + className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded text-lg font-mono" + placeholder="e.g., */5 * * * * or @hourly" + /> + {!isValid && ( +
+ Invalid format. Please enter a 5-field cron expression or use an alias like @hourly, @daily, @weekly, @monthly, @yearly +
+ )} +
+ + {/* Examples */} +
+ Examples: + {examples.map((ex) => ( + + ))} +
+ + {/* Explanation Output */} + {explanation && ( +
+
+
Human-Readable Explanation
+ +
+
+ {explanation} +
+
+ )} + + {/* Next Executions */} + {nextExecutions.length > 0 && ( +
+
+
Next 5 Executions
+ +
+
    + {nextExecutions.map((exec, idx) => ( +
  • + {idx + 1}. {exec} +
  • + ))} +
+
+ )} + + {/* Help Section */} +
+
+

Special Characters

+
    +
  • * - Any value (every)
  • +
  • , - List of values (e.g., 1,15,30)
  • +
  • - - Range of values (e.g., 9-17)
  • +
  • / - Step values (e.g., */5)
  • +
+
+
+

Supported Aliases

+
    +
  • @yearly or @annually - Once a year (Jan 1, 00:00)
  • +
  • @monthly - Once a month (1st, 00:00)
  • +
  • @weekly - Once a week (Sunday, 00:00)
  • +
  • @daily or @midnight - Once a day (00:00)
  • +
  • @hourly - Once an hour (minute 0)
  • +
+
+
+
+
+
+
+ ); +}; + +export default CrontabExplainer; diff --git a/app/libs/constants.tsx b/app/libs/constants.tsx index c51e71e..56c5b22 100644 --- a/app/libs/constants.tsx +++ b/app/libs/constants.tsx @@ -70,6 +70,7 @@ import CmykToRgbConverter from '../components/developmentToolsComponent/cmykToRg import ColorInvertor from '../components/developmentToolsComponent/colorInvertor'; import ColorPickerTool from '../components/developmentToolsComponent/colorPickerTool'; import CrontabGenerator from '../components/developmentToolsComponent/crontabGenerator'; +import CrontabExplainer from '../components/developmentToolsComponent/crontabExplainer'; import CssMinify from '../components/developmentToolsComponent/cssMinify'; import CssPrettify from '../components/developmentToolsComponent/cssPrettify'; import CssToLess from '../components/developmentToolsComponent/cssToLess'; @@ -1539,6 +1540,11 @@ export const developmentToolsCategoryContent: any = { title: 'Crontab Generator', description: 'Generate Crontab.', }, + { + url: '/crontab-explainer', + title: 'Crontab Explainer', + description: 'Explain Crontab expressions in plain English.', + }, ], Category169: [ { @@ -1766,6 +1772,7 @@ export const PATHS = { CSS_TO_SASS: '/css-to-sass', CSS_TO_LESS: '/css-to-less', CRONTAB_GENERATOR: '/crontab-generator', + CRONTAB_EXPLAINER: '/crontab-explainer', MORSE_CODE_TRANSLATOR: '/morse-code-translator', }; @@ -2442,6 +2449,10 @@ export const developmentToolsRoutes = [ path: PATHS.CRONTAB_GENERATOR, component: , }, + { + path: PATHS.CRONTAB_EXPLAINER, + component: , + }, { path: PATHS.MORSE_CODE_TRANSLATOR, component: , diff --git a/app/libs/developmentToolsConstant.tsx b/app/libs/developmentToolsConstant.tsx index aea9220..52b72c4 100644 --- a/app/libs/developmentToolsConstant.tsx +++ b/app/libs/developmentToolsConstant.tsx @@ -16973,6 +16973,88 @@ family[1]: "Beth"`, og_image: '/images/og-images/Cover.png', }, }, + [`crontab-explainer`]: { + hero_section: { + title: 'Crontab Explainer', + description: + 'Decode cron expressions into plain English and preview upcoming execution times.', + }, + development_tools_list: [ + { tool: 'Crontab Generator', url: PATHS.CRONTAB_GENERATOR }, + { tool: 'Regex Tester', url: PATHS.JAVASCRIPT_REGEX_TESTER }, + ], + development_tools_about_details: { + about_title: 'About Crontab Explainer', + about_description: [ + { + description: + 'The Crontab Explainer takes an existing cron expression and translates it into human-readable language. It supports standard 5-field cron syntax and common aliases like @hourly, @daily, @weekly, @monthly, and @yearly.', + }, + { + description: + 'This tool is perfect for understanding cron expressions you find in existing codebases, verifying that your scheduled jobs run at the expected times, and learning how cron syntax works.', + }, + ], + }, + development_tools_steps_guide: { + guide_title: 'How to Use', + guide_description: 'Explain a cron expression:', + steps: [ + { + step_key: 'Step 1:', + step_title: 'Enter expression', + step_description: + 'Paste or type a 5-field cron expression (e.g., */5 * * * *) or use an alias like @hourly.', + }, + { + step_key: 'Step 2:', + step_title: 'Read explanation', + step_description: + 'View the human-readable description of when the job will run.', + }, + { + step_key: 'Step 3:', + step_title: 'Preview executions', + step_description: + 'Check the next 5 scheduled execution times to verify the schedule.', + }, + ], + }, + development_tools_how_use: { + how_use_title: 'Use Cases', + how_use_description: 'When this tool helps:', + point: [ + { + title: 'Understanding existing cron jobs', + description: 'Decode cron expressions found in legacy code or server configurations.', + }, + { + title: 'Verifying schedules', + description: 'Confirm that a cron expression runs at the expected times before deployment.', + }, + { + title: 'Learning cron syntax', + description: 'Experiment with different expressions to understand how cron scheduling works.', + }, + { + title: 'Debugging scheduled tasks', + description: 'Identify why a job is not running as expected by checking its schedule.', + }, + { + title: 'Documentation', + description: 'Generate plain English descriptions for cron jobs in your documentation.', + }, + ], + }, + meta_data: { + meta_title: 'Crontab Explainer - Decode Cron Expressions', + meta_description: + 'Translate cron expressions into plain English and preview next execution times. Supports standard syntax and aliases like @hourly, @daily, @weekly.', + og_title: 'Crontab Explainer - Developer Utility', + og_description: 'Understand cron expressions with human-readable explanations and execution previews.', + og_image: '/images/og-images/Cover.png', + }, + }, [`morse-code-translator`]: { hero_section: { title: 'Morse Code Translator', From d5b9e8333673c5254cf39529a90869b1b741e385 Mon Sep 17 00:00:00 2001 From: Harryson <168550932+HarrysonLadines@users.noreply.github.com> Date: Sat, 28 Feb 2026 05:34:38 -0300 Subject: [PATCH 02/24] feat(ui): add reusable CopyButton and refactor wordCounter and jsonToTxt Adds a reusable CopyButton component and refactors WordCounter and JsonToTxt to use it. Fixes #17 --- .../developmentToolsComponent/jsonToTxt.tsx | 17 ++---- .../wordCounterComponent.tsx | 8 +++ app/components/ui/CopyButton.tsx | 58 +++++++++++++++++++ package-lock.json | 4 ++ 4 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 app/components/ui/CopyButton.tsx diff --git a/app/components/developmentToolsComponent/jsonToTxt.tsx b/app/components/developmentToolsComponent/jsonToTxt.tsx index c76a26a..5f1755c 100644 --- a/app/components/developmentToolsComponent/jsonToTxt.tsx +++ b/app/components/developmentToolsComponent/jsonToTxt.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; +import CopyButton from "../ui/CopyButton"; type Mode = "json-lines" | "keys" | "values" | "key=value" | "path=value"; @@ -87,11 +88,6 @@ const JsonToTxt: React.FC = () => { if (autoUpdate) convert(); }, [input, autoUpdate, mode, pretty, uniqueOnly]); - const onCopy = async () => { - try { - await navigator.clipboard.writeText(output); - } catch {} - }; const onDownload = () => { const blob = new Blob([output], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -201,12 +197,11 @@ const JsonToTxt: React.FC = () => {
- + + + +
diff --git a/app/components/ui/CopyButton.tsx b/app/components/ui/CopyButton.tsx new file mode 100644 index 0000000..e176ba0 --- /dev/null +++ b/app/components/ui/CopyButton.tsx @@ -0,0 +1,58 @@ +"use client"; + +import React, { useState } from "react"; +import { Copy, Check } from "@phosphor-icons/react"; +import { toast } from "react-toastify"; + +interface CopyButtonProps { + text: string; + variant?: "icon" | "text"; + className?: string; +} + +const CopyButton: React.FC = ({ + text, + variant = "icon", + className = "", +}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!text) return; + + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success("Copied!"); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + toast.error("Failed to copy"); + } + }; + + return ( + + ); +}; + +export default CopyButton; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ec0383b..5f5d810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,10 @@ "semantic-release": "^23.0.8", "tailwindcss": "^3.4.1", "typescript": "^5" + }, + "engines": { + "node": ">=20.8.0", + "npm": ">=10.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { From b4b89d6ad1e7e610fecd8efa56ca1cb0e06000c3 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 28 Feb 2026 08:35:49 +0000 Subject: [PATCH 03/24] chore(release): 1.4.0-develop.1 [skip ci] # [1.4.0-develop.1](https://github.com/betterbugs/dev-tools/compare/v1.3.2...v1.4.0-develop.1) (2026-02-28) ### Features * **ui:** add reusable CopyButton and refactor wordCounter and jsonToTxt ([d5b9e83](https://github.com/betterbugs/dev-tools/commit/d5b9e8333673c5254cf39529a90869b1b741e385)), closes [#17](https://github.com/betterbugs/dev-tools/issues/17) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74270e4..f83286b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.4.0-develop.1](https://github.com/betterbugs/dev-tools/compare/v1.3.2...v1.4.0-develop.1) (2026-02-28) + + +### Features + +* **ui:** add reusable CopyButton and refactor wordCounter and jsonToTxt ([d5b9e83](https://github.com/betterbugs/dev-tools/commit/d5b9e8333673c5254cf39529a90869b1b741e385)), closes [#17](https://github.com/betterbugs/dev-tools/issues/17) + ## [1.3.2](https://github.com/betterbugs/dev-tools/compare/v1.3.1...v1.3.2) (2026-02-16) From 94d19be7e4b8d9256557e7668898ec4d6c3ca15c Mon Sep 17 00:00:00 2001 From: Sayak Datta <174126344+datta-sayak@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:35:00 +0530 Subject: [PATCH 04/24] fix(tools): implement proper bcrypt generator Replaces placeholder hashing logic with bcryptjs implementation as contributed in PR #23 Adds proper salt generation and verification using bcrypt.compare. Fixes #13 --- .../bcryptGenerator.tsx | 38 ++++++++++--------- package-lock.json | 15 ++++++++ package.json | 2 + 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/app/components/developmentToolsComponent/bcryptGenerator.tsx b/app/components/developmentToolsComponent/bcryptGenerator.tsx index 84e0e63..aa5514b 100644 --- a/app/components/developmentToolsComponent/bcryptGenerator.tsx +++ b/app/components/developmentToolsComponent/bcryptGenerator.tsx @@ -1,5 +1,6 @@ "use client"; import React, { useState, useMemo } from "react"; +import bcrypt from 'bcryptjs'; // Custom styles for the range slider const sliderStyles = ` @@ -48,28 +49,29 @@ const BcryptGenerator = () => { // Simple bcrypt-like hash function (for demonstration - not cryptographically secure) const generateHash = async (text: string, rounds: number): Promise => { - // This is a simplified implementation for demo purposes - // In production, you would use a proper bcrypt library - const encoder = new TextEncoder(); - const data = encoder.encode(text + rounds.toString()); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return `$2b$${rounds}$${hashHex.substring(0, 53)}`; + return new Promise((resolve, reject) => { + bcrypt.genSalt(rounds, (err, salt) => { + if (err) return reject(err); + bcrypt.hash(text, salt, (err2, hash) => { + if (err2) return reject(err2); + resolve(hash); + }); + }); + }); }; // Simple verification function (for demonstration) const verifyHash = async (password: string, hash: string): Promise => { - try { - const parts = hash.split('$'); - if (parts.length !== 4 || parts[1] !== '2b') return false; - - const rounds = parseInt(parts[2]); - const generatedHash = await generateHash(password, rounds); - return generatedHash === hash; - } catch { - return false; - } + return new Promise((resolve) => { + try { + bcrypt.compare(password, hash, (err, res) => { + if (err) return resolve(false); + resolve(Boolean(res)); + }); + } catch { + resolve(false); + } + }); }; const handleGenerateHash = async () => { diff --git a/package-lock.json b/package-lock.json index 5f5d810..5c45c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/turndown": "5.0.5", "animejs": "3.2.2", "antd": "5.16.2", + "bcryptjs": "^2.4.3", "framer-motion": "11.2.6", "gleap": "^15.1.8", "javascript-obfuscator": "4.1.1", @@ -44,6 +45,7 @@ "@semantic-release/github": "^9.2.6", "@semantic-release/release-notes-generator": "^12.1.0", "@types/animejs": "^3.1.12", + "@types/bcryptjs": "^2.4.6", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", "@types/react": "^18", @@ -3661,6 +3663,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/css-font-loading-module": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", @@ -4820,6 +4829,12 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", diff --git a/package.json b/package.json index ffbbfb0..d90afa5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/turndown": "5.0.5", "animejs": "3.2.2", "antd": "5.16.2", + "bcryptjs": "^2.4.3", "framer-motion": "11.2.6", "gleap": "^15.1.8", "javascript-obfuscator": "4.1.1", @@ -70,6 +71,7 @@ "@semantic-release/github": "^9.2.6", "@semantic-release/release-notes-generator": "^12.1.0", "@types/animejs": "^3.1.12", + "@types/bcryptjs": "^2.4.6", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20", "@types/react": "^18", From 9598e98f830c40f3cfc8b4085981290f0738bfed Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 28 Feb 2026 09:06:13 +0000 Subject: [PATCH 05/24] chore(release): 1.4.0-develop.2 [skip ci] # [1.4.0-develop.2](https://github.com/betterbugs/dev-tools/compare/v1.4.0-develop.1...v1.4.0-develop.2) (2026-02-28) ### Bug Fixes * **tools:** implement proper bcrypt generator ([94d19be](https://github.com/betterbugs/dev-tools/commit/94d19be7e4b8d9256557e7668898ec4d6c3ca15c)), closes [#23](https://github.com/betterbugs/dev-tools/issues/23) [#13](https://github.com/betterbugs/dev-tools/issues/13) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f83286b..1d7bd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [1.4.0-develop.2](https://github.com/betterbugs/dev-tools/compare/v1.4.0-develop.1...v1.4.0-develop.2) (2026-02-28) + + +### Bug Fixes + +* **tools:** implement proper bcrypt generator ([94d19be](https://github.com/betterbugs/dev-tools/commit/94d19be7e4b8d9256557e7668898ec4d6c3ca15c)), closes [#23](https://github.com/betterbugs/dev-tools/issues/23) [#13](https://github.com/betterbugs/dev-tools/issues/13) + # [1.4.0-develop.1](https://github.com/betterbugs/dev-tools/compare/v1.3.2...v1.4.0-develop.1) (2026-02-28) From 112f1f7d9d85ad7630c6a479e4469399088245fd Mon Sep 17 00:00:00 2001 From: Omkar <182200831+omkarhole@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:39:36 +0530 Subject: [PATCH 06/24] Add Curl to Code Converter (#25) closes: #21 Co-authored-by: Syed Fahad --- .../curlToCodeConverter.tsx | 738 ++++++++++++++++++ app/libs/constants.tsx | 14 + app/libs/developmentToolsConstant.tsx | 93 +++ package.json | 1 + 4 files changed, 846 insertions(+) create mode 100644 app/components/developmentToolsComponent/curlToCodeConverter.tsx diff --git a/app/components/developmentToolsComponent/curlToCodeConverter.tsx b/app/components/developmentToolsComponent/curlToCodeConverter.tsx new file mode 100644 index 0000000..039a945 --- /dev/null +++ b/app/components/developmentToolsComponent/curlToCodeConverter.tsx @@ -0,0 +1,738 @@ +"use client"; +import React, { useState, useCallback } from "react"; +import DevelopmentToolsStyles from "../../developmentToolsStyles.module.scss"; +import CopyIcon from "../theme/Icon/copyIcon"; +import ReloadIcon from "../theme/Icon/reload"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface ParsedCurl { + url: string; + method: string; + headers: Record; + body: string | null; + auth: { user: string; password: string } | null; + isFormData: boolean; + isJson: boolean; +} + +type Language = + | "js-fetch" + | "js-axios" + | "python-requests" + | "go" + | "nodejs"; + +// ─── Parser ────────────────────────────────────────────────────────────────── + +function tokenizeCurl(input: string): string[] { + const tokens: string[] = []; + let i = 0; + const s = input.trim().replace(/\\\n/g, " ").replace(/\\\r\n/g, " "); + + while (i < s.length) { + // Skip whitespace + while (i < s.length && /\s/.test(s[i])) i++; + if (i >= s.length) break; + + if (s[i] === "'") { + // Single-quoted string + i++; + let tok = ""; + while (i < s.length && s[i] !== "'") { + tok += s[i++]; + } + i++; // closing quote + tokens.push(tok); + } else if (s[i] === '"') { + // Double-quoted string (handle basic escapes) + i++; + let tok = ""; + while (i < s.length && s[i] !== '"') { + if (s[i] === "\\" && i + 1 < s.length) { + i++; + const c = s[i]; + if (c === "n") tok += "\n"; + else if (c === "t") tok += "\t"; + else if (c === "r") tok += "\r"; + else tok += c; + } else { + tok += s[i]; + } + i++; + } + i++; // closing quote + tokens.push(tok); + } else { + // Unquoted token + let tok = ""; + while (i < s.length && !/\s/.test(s[i])) { + tok += s[i++]; + } + tokens.push(tok); + } + } + return tokens; +} + +function parseCurl(raw: string): ParsedCurl | null { + const tokens = tokenizeCurl(raw); + if (!tokens.length) return null; + + // Must start with 'curl' + if (tokens[0].toLowerCase() !== "curl") return null; + + let url = ""; + let method = ""; + const headers: Record = {}; + let body: string | null = null; + let auth: { user: string; password: string } | null = null; + + for (let i = 1; i < tokens.length; i++) { + const tok = tokens[i]; + + if (tok === "-X" || tok === "--request") { + method = tokens[++i]?.toUpperCase() ?? ""; + } else if (tok === "-H" || tok === "--header") { + const hdr = tokens[++i] ?? ""; + const colon = hdr.indexOf(":"); + if (colon !== -1) { + const key = hdr.slice(0, colon).trim(); + const val = hdr.slice(colon + 1).trim(); + headers[key] = val; + } + } else if ( + tok === "-d" || + tok === "--data" || + tok === "--data-raw" || + tok === "--data-binary" || + tok === "--data-ascii" + ) { + body = tokens[++i] ?? ""; + } else if (tok === "-u" || tok === "--user") { + const creds = tokens[++i] ?? ""; + const idx = creds.indexOf(":"); + if (idx !== -1) { + auth = { + user: creds.slice(0, idx), + password: creds.slice(idx + 1), + }; + } else { + auth = { user: creds, password: "" }; + } + } else if (tok === "--json") { + body = tokens[++i] ?? ""; + headers["Content-Type"] = "application/json"; + headers["Accept"] = "application/json"; + } else if (tok === "--form" || tok === "-F") { + // Basic form data support - collect first occurrence + if (!body) body = tokens[++i] ?? ""; + else i++; // skip but don't overwrite + } else if ( + tok === "--compressed" || + tok === "-L" || + tok === "--location" || + tok === "-k" || + tok === "--insecure" || + tok === "-s" || + tok === "--silent" || + tok === "-v" || + tok === "--verbose" || + tok === "-i" || + tok === "--include" + ) { + // Silently ignore these flags + } else if (tok === "-b" || tok === "--cookie") { + const cookie = tokens[++i] ?? ""; + headers["Cookie"] = cookie; + } else if (tok === "-A" || tok === "--user-agent") { + headers["User-Agent"] = tokens[++i] ?? ""; + } else if (tok === "-e" || tok === "--referer") { + headers["Referer"] = tokens[++i] ?? ""; + } else if (!tok.startsWith("-")) { + // Positional arg = URL + if (!url) url = tok; + } + } + + if (!url) return null; + + // Detect method + if (!method) { + method = body ? "POST" : "GET"; + } + + // Detect content type + const contentType = headers["Content-Type"] || headers["content-type"] || ""; + const isJson = + contentType.includes("application/json") || + (body !== null && body.trimStart().startsWith("{")) || + (body !== null && body.trimStart().startsWith("[")); + const isFormData = contentType.includes("application/x-www-form-urlencoded"); + + return { url, method, headers, body, auth, isFormData, isJson }; +} + +// ─── Code Generators ───────────────────────────────────────────────────────── + +function indent(n: number): string { + return " ".repeat(n); +} + +function jsStringify(val: string): string { + return JSON.stringify(val); +} + +function generateJsFetch(p: ParsedCurl): string { + const lines: string[] = []; + const options: string[] = []; + + if (p.method !== "GET") { + options.push(`${indent(1)}method: ${jsStringify(p.method)},`); + } + + const headerEntries = Object.entries(p.headers); + if (p.auth) { + const encoded = Buffer.from( + `${p.auth.user}:${p.auth.password}` + ).toString("base64"); + headerEntries.push(["Authorization", `Basic ${encoded}`]); + } + + if (headerEntries.length) { + options.push(`${indent(1)}headers: {`); + for (const [k, v] of headerEntries) { + options.push(`${indent(2)}${jsStringify(k)}: ${jsStringify(v)},`); + } + options.push(`${indent(1)}},`); + } + + if (p.body !== null) { + if (p.isJson) { + try { + const parsed = JSON.parse(p.body); + options.push( + `${indent(1)}body: JSON.stringify(${JSON.stringify(parsed, null, 2) + .split("\n") + .join("\n" + indent(1))}),` + ); + } catch { + options.push(`${indent(1)}body: ${jsStringify(p.body)},`); + } + } else { + options.push(`${indent(1)}body: ${jsStringify(p.body)},`); + } + } + + lines.push(`const response = await fetch(${jsStringify(p.url)}, {`); + lines.push(...options); + lines.push(`});`); + lines.push(`const data = await response.json();`); + lines.push(`console.log(data);`); + + return lines.join("\n"); +} + +function generateJsAxios(p: ParsedCurl): string { + const lines: string[] = []; + + const configParts: string[] = []; + configParts.push(`${indent(1)}method: ${jsStringify(p.method.toLowerCase())},`); + configParts.push(`${indent(1)}url: ${jsStringify(p.url)},`); + + const headerEntries = Object.entries(p.headers); + if (p.auth) { + lines.unshift(`// Axios handles basic auth natively:`); + configParts.push(`${indent(1)}auth: {`); + configParts.push(`${indent(2)}username: ${jsStringify(p.auth.user)},`); + configParts.push(`${indent(2)}password: ${jsStringify(p.auth.password)},`); + configParts.push(`${indent(1)}},`); + } + + if (headerEntries.length) { + configParts.push(`${indent(1)}headers: {`); + for (const [k, v] of headerEntries) { + configParts.push(`${indent(2)}${jsStringify(k)}: ${jsStringify(v)},`); + } + configParts.push(`${indent(1)}},`); + } + + if (p.body !== null) { + if (p.isJson) { + try { + const parsed = JSON.parse(p.body); + const jsonStr = JSON.stringify(parsed, null, 2) + .split("\n") + .join("\n" + indent(1)); + configParts.push(`${indent(1)}data: ${jsonStr},`); + } catch { + configParts.push(`${indent(1)}data: ${jsStringify(p.body)},`); + } + } else { + configParts.push(`${indent(1)}data: ${jsStringify(p.body)},`); + } + } + + lines.push(`import axios from 'axios';`); + lines.push(``); + lines.push(`const response = await axios({`); + lines.push(...configParts); + lines.push(`});`); + lines.push(`console.log(response.data);`); + + return lines.join("\n"); +} + +function generatePythonRequests(p: ParsedCurl): string { + const lines: string[] = []; + lines.push(`import requests`); + lines.push(``); + + const args: string[] = ["url"]; + + // Headers + const headerEntries = Object.entries(p.headers); + if (headerEntries.length) { + lines.push(`headers = {`); + for (const [k, v] of headerEntries) { + lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`); + } + lines.push(`}`); + lines.push(``); + args.push(`headers=headers`); + } + + // Auth + if (p.auth) { + args.push( + `auth=(${JSON.stringify(p.auth.user)}, ${JSON.stringify(p.auth.password)})` + ); + } + + // Body + if (p.body !== null) { + if (p.isJson) { + try { + const parsed = JSON.parse(p.body); + const pyJson = JSON.stringify(parsed, null, 4) + .replace(/: null/g, ": None") + .replace(/: true/g, ": True") + .replace(/: false/g, ": False"); + lines.push(`payload = ${pyJson}`); + } catch { + lines.push(`payload = ${JSON.stringify(p.body)}`); + } + lines.push(``); + args.push(`json=payload`); + } else if (p.isFormData) { + lines.push(`data = ${JSON.stringify(p.body)}`); + lines.push(``); + args.push(`data=data`); + } else { + lines.push(`data = ${JSON.stringify(p.body)}`); + lines.push(``); + args.push(`data=data`); + } + } + + const methodLower = p.method.toLowerCase(); + lines.push(`url = ${JSON.stringify(p.url)}`); + lines.push(``); + lines.push(`response = requests.${methodLower}(${args.join(", ")})`); + lines.push(`print(response.json())`); + + return lines.join("\n"); +} + +function generateGo(p: ParsedCurl): string { + const lines: string[] = []; + + lines.push(`package main`); + lines.push(``); + lines.push(`import (`); + lines.push(`\t"fmt"`); + if (p.body) lines.push(`\t"strings"`); + lines.push(`\t"net/http"`); + lines.push(`\t"io"`); + lines.push(`)`); + lines.push(``); + lines.push(`func main() {`); + lines.push(`\turl := ${JSON.stringify(p.url)}`); + lines.push(``); + + if (p.body) { + const bodyStr = p.isJson + ? (() => { + try { + return JSON.stringify(JSON.parse(p.body), null, 2); + } catch { + return p.body; + } + })() + : p.body; + lines.push(`\tbody := strings.NewReader(${JSON.stringify(bodyStr)})`); + lines.push( + `\treq, err := http.NewRequest(${JSON.stringify(p.method)}, url, body)` + ); + } else { + lines.push( + `\treq, err := http.NewRequest(${JSON.stringify(p.method)}, url, nil)` + ); + } + + lines.push(`\tif err != nil {`); + lines.push(`\t\tpanic(err)`); + lines.push(`\t}`); + lines.push(``); + + for (const [k, v] of Object.entries(p.headers)) { + lines.push(`\treq.Header.Set(${JSON.stringify(k)}, ${JSON.stringify(v)})`); + } + + if (p.auth) { + lines.push( + `\treq.SetBasicAuth(${JSON.stringify(p.auth.user)}, ${JSON.stringify(p.auth.password)})` + ); + } + + lines.push(``); + lines.push(`\tclient := &http.Client{}`); + lines.push(`\tresp, err := client.Do(req)`); + lines.push(`\tif err != nil {`); + lines.push(`\t\tpanic(err)`); + lines.push(`\t}`); + lines.push(`\tdefer resp.Body.Close()`); + lines.push(``); + lines.push(`\tbody2, _ := io.ReadAll(resp.Body)`); + lines.push(`\tfmt.Println(string(body2))`); + lines.push(`}`); + + return lines.join("\n"); +} + +function generateNodeJs(p: ParsedCurl): string { + const lines: string[] = []; + + try { + const urlObj = new URL(p.url); + const protocol = urlObj.protocol.replace(":", ""); + const hostname = urlObj.hostname; + const port = + urlObj.port || (protocol === "https" ? "443" : "80"); + const path = urlObj.pathname + (urlObj.search || ""); + + lines.push(`const ${protocol} = require('${protocol}');`); + lines.push(``); + + if (p.body) { + const bodyStr = p.isJson + ? (() => { + try { + return JSON.stringify(JSON.parse(p.body), null, 2); + } catch { + return p.body; + } + })() + : p.body; + lines.push(`const postData = ${JSON.stringify(bodyStr)};`); + lines.push(``); + } + + lines.push(`const options = {`); + lines.push(` hostname: ${JSON.stringify(hostname)},`); + if ( + (protocol === "https" && port !== "443") || + (protocol === "http" && port !== "80") + ) { + lines.push(` port: ${port},`); + } + lines.push(` path: ${JSON.stringify(path || "/")},`); + lines.push(` method: ${JSON.stringify(p.method)},`); + + const headerEntries = Object.entries(p.headers); + if (p.body) { + headerEntries.push(["Content-Length", "Buffer.byteLength(postData)"]); + } + if (p.auth) { + const creds = `${p.auth.user}:${p.auth.password}`; + const encoded = Buffer.from(creds).toString("base64"); + headerEntries.push(["Authorization", `Basic ${encoded}`]); + } + + if (headerEntries.length) { + lines.push(` headers: {`); + for (const [k, v] of headerEntries) { + if (k === "Content-Length") { + lines.push(` ${JSON.stringify(k)}: ${v},`); + } else { + lines.push(` ${JSON.stringify(k)}: ${JSON.stringify(v)},`); + } + } + lines.push(` },`); + } + + lines.push(`};`); + lines.push(``); + lines.push(`const req = ${protocol}.request(options, (res) => {`); + lines.push(` let data = '';`); + lines.push(` res.on('data', (chunk) => { data += chunk; });`); + lines.push(` res.on('end', () => { console.log(JSON.parse(data)); });`); + lines.push(`});`); + lines.push(``); + lines.push(`req.on('error', (e) => { console.error(e); });`); + + if (p.body) { + lines.push(`req.write(postData);`); + } + + lines.push(`req.end();`); + } catch { + // Fallback if URL parsing fails + lines.push(`// Could not fully parse the URL: ${p.url}`); + lines.push( + `// Please adjust hostname, path and port below.` + ); + lines.push(`const https = require('https');`); + lines.push(`// ... (manual setup required)`); + } + + return lines.join("\n"); +} + +function generateCode(parsed: ParsedCurl, lang: Language): string { + switch (lang) { + case "js-fetch": + return generateJsFetch(parsed); + case "js-axios": + return generateJsAxios(parsed); + case "python-requests": + return generatePythonRequests(parsed); + case "go": + return generateGo(parsed); + case "nodejs": + return generateNodeJs(parsed); + default: + return ""; + } +} + +// ─── Sample cURL commands ──────────────────────────────────────────────────── + +const SAMPLES: Record = { + "GET with headers": `curl https://api.example.com/users \\\n -H 'Authorization: Bearer YOUR_TOKEN' \\\n -H 'Accept: application/json'`, + "POST JSON": `curl -X POST https://api.example.com/users \\\n -H 'Content-Type: application/json' \\\n -H 'Authorization: Bearer YOUR_TOKEN' \\\n -d '{"name":"John Doe","email":"john@example.com"}'`, + "PUT with body": `curl -X PUT https://api.example.com/users/42 \\\n -H 'Content-Type: application/json' \\\n -d '{"name":"Jane Doe"}'`, + "Basic auth": `curl https://api.example.com/secure \\\n -u username:password`, + "Form data": `curl -X POST https://api.example.com/login \\\n -H 'Content-Type: application/x-www-form-urlencoded' \\\n -d 'username=john&password=secret'`, +}; + +const LANGUAGES: { value: Language; label: string }[] = [ + { value: "js-fetch", label: "JavaScript (Fetch)" }, + { value: "js-axios", label: "JavaScript (Axios)" }, + { value: "python-requests", label: "Python (Requests)" }, + { value: "go", label: "Go" }, + { value: "nodejs", label: "Node.js" }, +]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +const CurlToCodeConverter = () => { + const [curlInput, setCurlInput] = useState(""); + const [language, setLanguage] = useState("js-fetch"); + const [output, setOutput] = useState(""); + const [error, setError] = useState(""); + const [copySuccess, setCopySuccess] = useState(false); + + const convert = useCallback( + (raw: string, lang: Language) => { + const trimmed = raw.trim(); + if (!trimmed) { + setOutput(""); + setError(""); + return; + } + const parsed = parseCurl(trimmed); + if (!parsed) { + setOutput(""); + setError( + "Could not parse the cURL command. Make sure it starts with 'curl' and includes a URL." + ); + return; + } + setError(""); + setOutput(generateCode(parsed, lang)); + }, + [] + ); + + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setCurlInput(val); + convert(val, language); + }; + + const handleLanguageChange = (e: React.ChangeEvent) => { + const lang = e.target.value as Language; + setLanguage(lang); + convert(curlInput, lang); + }; + + const handleLoadSample = (sampleKey: string) => { + const sample = SAMPLES[sampleKey]; + if (sample) { + setCurlInput(sample); + convert(sample, language); + } + }; + + const handleCopy = async () => { + if (!output) return; + try { + await navigator.clipboard.writeText(output); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + } catch { + setError("Failed to copy to clipboard."); + } + }; + + const handleClear = () => { + setCurlInput(""); + setOutput(""); + setError(""); + setCopySuccess(false); + }; + + return ( +
+
+
+
+
+ {/* Controls row */} +
+ {/* Language selector */} +
+ + +
+ + {/* Samples */} +
+ + +
+ + {/* Action buttons */} +
+ + +
+
+ + {/* Editor area */} +
+ {/* Input */} +
+ + + {svgInput && ( + + )} + +
+ {error && ( +
{error}
+ )} +
+ + {/* Output */} +
+

Optimized Output

+
+ + {output && ( + + )} +
+
+
+
+
+
+ +
+ ); +}; + +export default SvgConverter; diff --git a/app/libs/constants.tsx b/app/libs/constants.tsx index 5974493..95fec36 100644 --- a/app/libs/constants.tsx +++ b/app/libs/constants.tsx @@ -153,6 +153,7 @@ import SqlFormatterAndBeautifier from '../components/developmentToolsComponent/s import SqlMinify from '../components/developmentToolsComponent/sqlMinify'; import SqlToCsvConverter from '../components/developmentToolsComponent/sqlToCsvConverter'; import SqlToJson from '../components/developmentToolsComponent/sqlToJson'; +import SvgConverter from '../components/developmentToolsComponent/svgConverter'; import StringDiffrenceChecker from '../components/developmentToolsComponent/stringDiffrenceChecker'; import StripHTML from '../components/developmentToolsComponent/stripHTML'; import TextCompare from '../components/developmentToolsComponent/textCompare'; @@ -1607,6 +1608,14 @@ export const developmentToolsCategoryContent: any = { description: 'Calculate subnet details like network address, broadcast address, and usable host range.', }, ], + Category177: [ + { + url: '/svg-converter', + title: 'SVG to React/CSS Utility', + description: + 'Convert SVG to optimized React components, CSS Data URIs, or CSS Masks.', + }, + ], }; export const PATHS = { @@ -1765,6 +1774,7 @@ export const PATHS = { HTML_UNESCAPE: '/html-unescape', JAVASCRIPT_REGEX_TESTER: '/javascript-regex-tester', STRIP_HTML: '/strip-html', + SVG_CONVERTER: '/svg-converter', WHAT_IS_MY_LOCAL_IP_ADDRESS: '/what-is-my-local-ip-address', JAVASCRIPT_TESTER: '/javascript-tester', WHAT_VERSION_OF_JAVA: '/what-version-of-java-do-i-have', @@ -2491,6 +2501,10 @@ export const developmentToolsRoutes = [ path: PATHS.SQL_TO_JSON, component: , }, + { + path: PATHS.SVG_CONVERTER, + component: , + }, { path: PATHS.HTML_TO_JADE, component: , diff --git a/app/libs/developmentToolsConstant.tsx b/app/libs/developmentToolsConstant.tsx index 0624c4a..fb303c8 100644 --- a/app/libs/developmentToolsConstant.tsx +++ b/app/libs/developmentToolsConstant.tsx @@ -15543,6 +15543,95 @@ family[1]: "Beth"`, og_image: '/images/og-images/Cover.png', }, }, + [`svg-converter`]: { + hero_section: { + title: 'SVG to React/CSS Utility', + description: + 'Convert raw SVG code to optimized React components, CSS Data URIs, or CSS Masks for different development needs.', + }, + development_tools_list: [ + { tool: 'Base64 Encoder', url: PATHS.BASE64_ENCODER }, + { tool: 'CSS Minify', url: PATHS.CSS_MINIFY }, + { tool: 'Color Inveror', url: PATHS.COLOR_INVERTOR }, + { tool: 'QR Code Generator', url: PATHS.QR_CODE_GENERATOR }, + ], + development_tools_about_details: { + about_title: 'What is the SVG Converter?', + about_description: [ + { + description: + 'The SVG Converter transforms raw SVG code into optimized variants for different use cases: clean React components, CSS Data URIs, or CSS Masks.', + }, + { + description: + 'Automates SVG cleanup (removing metadata), handles viewBox preservation, and supports dynamic sizing and color theming options.', + }, + ], + }, + development_tools_steps_guide: { + guide_title: 'How to Use', + guide_description: 'Follow these simple steps:', + steps: [ + { + step_key: 'Step 1:', + step_title: 'Paste SVG Code:', + step_description: 'Paste your SVG code or upload an SVG file.', + }, + { + step_key: 'Step 2:', + step_title: 'Choose Output Format:', + step_description: + 'Select React Component, CSS Data URI, or CSS Mask from the dropdown.', + }, + { + step_key: 'Step 3:', + step_title: 'Configure Options:', + step_description: + 'Set default width, height, and choose whether to use currentColor for fills.', + }, + { + step_key: 'Step 4:', + step_title: 'Copy Output:', + step_description: 'Copy the optimized code and use it in your project.', + }, + ], + }, + development_tools_how_use: { + how_use_title: 'Common Use Cases', + how_use_description: 'Popular reasons to use this tool:', + point: [ + { + title: 'React Component Generation', + description: + 'Automatically create reusable React icon components from SVG files with dynamic sizing and theming support.', + }, + { + title: 'CSS Background Images', + description: + 'Generate inline SVG Data URIs for use as CSS background images without external file requests.', + }, + { + title: 'Icon Masking', + description: + 'Convert SVGs to CSS mask properties for flexible icon styling and color customization.', + }, + { + title: 'Metadata Cleanup', + description: + 'Automatically remove unnecessary metadata, comments, and attributes from design tool exports.', + }, + ], + }, + meta_data: { + meta_title: 'SVG to React/CSS Converter – Free Online Tool', + meta_description: + 'Convert SVG to React components, CSS Data URIs, or CSS Masks. Remove metadata and optimize for web development.', + og_title: 'SVG Converter – Optimize SVGs for Development', + og_description: + 'Transform SVG files into React components, CSS URIs, or masks with one click. Supports custom dimensions and color theming.', + og_image: '/images/og-images/Cover.png', + }, + }, [`what-is-my-local-ip-address`]: { hero_section: { title: 'What Is My Local IP Address', From be3a012c6d7df84ab5826ce03268ec8aad402c15 Mon Sep 17 00:00:00 2001 From: Sfahad7 Date: Thu, 5 Mar 2026 14:49:57 +0530 Subject: [PATCH 16/24] fix(release): update GITHUB_TOKEN to use RELEASE_TOKEN for semantic release --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb9a7fe..7a3afa3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,5 +35,6 @@ jobs: - name: Run semantic release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} run: npx semantic-release From 6da2294290e9fdf51f8f17f01ec4d70fb4c6e6dd Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 5 Mar 2026 09:21:22 +0000 Subject: [PATCH 17/24] chore(release): 1.4.0-develop.5 [skip ci] # [1.4.0-develop.5](https://github.com/betterbugs/dev-tools/compare/v1.4.0-develop.4...v1.4.0-develop.5) (2026-03-05) ### Bug Fixes * **release:** update GITHUB_TOKEN to use RELEASE_TOKEN for semantic release ([be3a012](https://github.com/betterbugs/dev-tools/commit/be3a012c6d7df84ab5826ce03268ec8aad402c15)) ### Features * **tools:** add SVG to React/CSS utility ([218ccad](https://github.com/betterbugs/dev-tools/commit/218ccad3c5eb7121a9bd7319147520eb39713695)), closes [#50](https://github.com/betterbugs/dev-tools/issues/50) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa0adf..a1206fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# [1.4.0-develop.5](https://github.com/betterbugs/dev-tools/compare/v1.4.0-develop.4...v1.4.0-develop.5) (2026-03-05) + + +### Bug Fixes + +* **release:** update GITHUB_TOKEN to use RELEASE_TOKEN for semantic release ([be3a012](https://github.com/betterbugs/dev-tools/commit/be3a012c6d7df84ab5826ce03268ec8aad402c15)) + + +### Features + +* **tools:** add SVG to React/CSS utility ([218ccad](https://github.com/betterbugs/dev-tools/commit/218ccad3c5eb7121a9bd7319147520eb39713695)), closes [#50](https://github.com/betterbugs/dev-tools/issues/50) + # [1.4.0-develop.4](https://github.com/betterbugs/dev-tools/compare/v1.4.0-develop.3...v1.4.0-develop.4) (2026-03-03) From 5b4791e97eda887e87c057a5522f1c8fec0c674e Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:07:37 +0530 Subject: [PATCH 18/24] feat. add crontab explainer --- .../crontabExplainer.tsx | 374 ++++++++++++++++++ app/libs/constants.tsx | 11 + app/libs/developmentToolsConstant.tsx | 82 ++++ 3 files changed, 467 insertions(+) create mode 100644 app/components/developmentToolsComponent/crontabExplainer.tsx diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx new file mode 100644 index 0000000..0cceffc --- /dev/null +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -0,0 +1,374 @@ +"use client"; +import React, { useMemo, useState } from "react"; + +const Cmd = ({ children }: { children: string }) => ( + {children} +); + +// Cron aliases mapping +const ALIASES: Record = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +}; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +// Parse a cron field into human-readable text +const parseField = (field: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { + if (field === "*") { + if (type === "minute") return "every minute"; + if (type === "hour") return "every hour"; + if (type === "dom") return "every day"; + if (type === "month") return "every month"; + return "every day of week"; + } + + // Handle step values (*/n or x/n) + if (field.includes("/")) { + const [base, step] = field.split("/"); + const stepNum = parseInt(step); + if (base === "*") { + if (type === "minute") return `every ${stepNum} minute${stepNum > 1 ? "s" : ""}`; + if (type === "hour") return `every ${stepNum} hour${stepNum > 1 ? "s" : ""}`; + if (type === "dom") return `every ${stepNum} day${stepNum > 1 ? "s" : ""}`; + if (type === "month") return `every ${stepNum} month${stepNum > 1 ? "s" : ""}`; + return `every ${stepNum} day${stepNum > 1 ? "s" : ""} of week`; + } + return `every ${stepNum} starting from ${base}`; + } + + // Handle ranges (x-y) + if (field.includes("-") && !field.includes(",")) { + const [start, end] = field.split("-"); + const startLabel = formatValue(start, type); + const endLabel = formatValue(end, type); + return `from ${startLabel} to ${endLabel}`; + } + + // Handle lists (x,y,z) + if (field.includes(",")) { + const values = field.split(",").map((v) => formatValue(v.trim(), type)); + if (values.length > 3) { + return `at ${values.slice(0, 3).join(", ")}, and ${values.length - 3} more`; + } + return `at ${values.join(", ")}`; + } + + // Single value + return `at ${formatValue(field, type)}`; +}; + +const formatValue = (value: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { + const num = parseInt(value); + if (isNaN(num)) { + // Handle month/day names + if (type === "month") { + const monthIndex = MONTHS.findIndex((m) => m.toLowerCase() === value.toLowerCase()); + return monthIndex >= 0 ? MONTHS[monthIndex] : value; + } + if (type === "dow") { + const dowIndex = DOW.findIndex((d) => d.toLowerCase() === value.toLowerCase()); + return dowIndex >= 0 ? DOW[dowIndex] : value; + } + return value; + } + + if (type === "month" && num >= 1 && num <= 12) return MONTHS[num - 1]; + if (type === "dow" && num >= 0 && num <= 6) return DOW[num]; + if (type === "hour") return `${num.toString().padStart(2, "0")}:00`; + if (type === "minute") return `minute ${num}`; + return value; +}; + +// Generate human-readable explanation +const explainCron = (cronExpr: string): string => { + const trimmed = cronExpr.trim().toLowerCase(); + + // Check for aliases + if (ALIASES[trimmed]) { + const aliasName = trimmed.substring(1); // Remove @ + return `Runs ${aliasName} (${ALIASES[trimmed]})`; + } + + const parts = cronExpr.trim().split(/\s+/); + if (parts.length !== 5) { + return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; + } + + const [minute, hour, dom, month, dow] = parts; + + const minuteText = parseField(minute, "minute"); + const hourText = parseField(hour, "hour"); + const domText = parseField(dom, "dom"); + const monthText = parseField(month, "month"); + const dowText = parseField(dow, "dow"); + + // Build natural sentence + let explanation = "Runs "; + + // Time part + if (minute === "*" && hour === "*") { + explanation += "every minute"; + } else if (minute !== "*" && hour === "*") { + explanation += `${minuteText} of every hour`; + } else if (minute === "*" && hour !== "*") { + explanation += `every minute ${hourText}`; + } else { + explanation += `at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; + } + + // Day constraints + const hasDom = dom !== "*"; + const hasDow = dow !== "*"; + + if (hasDom && hasDow) { + explanation += `, ${domText} of the month OR ${dowText}`; + } else if (hasDom) { + explanation += `, ${domText} of the month`; + } else if (hasDow) { + explanation += `, ${dowText}`; + } + + // Month constraint + if (month !== "*") { + explanation += `, ${monthText}`; + } + + return explanation; +}; + +// Calculate next N executions +const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { + try { + const trimmed = cronExpr.trim().toLowerCase(); + const expr = ALIASES[trimmed] || cronExpr.trim(); + + const parts = expr.split(/\s+/); + if (parts.length !== 5) return []; + + const [minuteField, hourField, domField, monthField, dowField] = parts; + + const now = new Date(); + const executions: string[] = []; + let current = new Date(now); + current.setSeconds(0); + current.setMilliseconds(0); + + // Simple implementation - check next 10000 minutes + for (let i = 0; i < 10000 && executions.length < count; i++) { + current = new Date(current.getTime() + 60000); // Add 1 minute + + const minute = current.getMinutes(); + const hour = current.getHours(); + const dom = current.getDate(); + const month = current.getMonth() + 1; + const dow = current.getDay(); + + if ( + matchField(minute, minuteField, 0, 59) && + matchField(hour, hourField, 0, 23) && + matchField(dom, domField, 1, 31) && + matchField(month, monthField, 1, 12) && + matchField(dow, dowField, 0, 6) + ) { + executions.push(current.toLocaleString()); + } + } + + return executions; + } catch { + return []; + } +}; + +const matchField = (value: number, field: string, min: number, max: number): boolean => { + if (field === "*") return true; + + // Handle step values + if (field.includes("/")) { + const [base, step] = field.split("/"); + const stepNum = parseInt(step); + if (base === "*") { + return value % stepNum === 0; + } + const baseNum = parseInt(base); + return value >= baseNum && (value - baseNum) % stepNum === 0; + } + + // Handle ranges + if (field.includes("-") && !field.includes(",")) { + const [start, end] = field.split("-").map((v) => parseInt(v)); + return value >= start && value <= end; + } + + // Handle lists + if (field.includes(",")) { + const values = field.split(",").map((v) => parseInt(v.trim())); + return values.includes(value); + } + + // Single value + return value === parseInt(field); +}; + +const CrontabExplainer = () => { + const [cronInput, setCronInput] = useState(""); + const [showExecutions, setShowExecutions] = useState(true); + + const explanation = useMemo(() => { + if (!cronInput.trim()) return ""; + return explainCron(cronInput); + }, [cronInput]); + + const nextExecutions = useMemo(() => { + if (!cronInput.trim() || !showExecutions) return []; + return getNextExecutions(cronInput, 5); + }, [cronInput, showExecutions]); + + const isValid = useMemo(() => { + if (!cronInput.trim()) return true; + const trimmed = cronInput.trim().toLowerCase(); + if (ALIASES[trimmed]) return true; + const parts = cronInput.trim().split(/\s+/); + return parts.length === 5; + }, [cronInput]); + + const examples = [ + { label: "Every 5 minutes", cron: "*/5 * * * *" }, + { label: "Daily at 2:30 AM", cron: "30 2 * * *" }, + { label: "Weekdays at 9 AM", cron: "0 9 * * 1-5" }, + { label: "First day of month", cron: "0 0 1 * *" }, + { label: "@hourly", cron: "@hourly" }, + { label: "@daily", cron: "@daily" }, + ]; + + const handleCopy = async () => { + if (!explanation) return; + try { + await navigator.clipboard.writeText(explanation); + } catch {} + }; + + return ( +
+
+
+
+ {/* Input Section */} +
+
+ +
+ Format: minute hour day month weekday +
+
+ setCronInput(e.target.value)} + className="w-full px-4 py-3 bg-black/40 border border-white/10 rounded text-lg font-mono" + placeholder="e.g., */5 * * * * or @hourly" + /> + {!isValid && ( +
+ Invalid format. Please enter a 5-field cron expression or use an alias like @hourly, @daily, @weekly, @monthly, @yearly +
+ )} +
+ + {/* Examples */} +
+ Examples: + {examples.map((ex) => ( + + ))} +
+ + {/* Explanation Output */} + {explanation && ( +
+
+
Human-Readable Explanation
+ +
+
+ {explanation} +
+
+ )} + + {/* Next Executions */} + {nextExecutions.length > 0 && ( +
+
+
Next 5 Executions
+ +
+
    + {nextExecutions.map((exec, idx) => ( +
  • + {idx + 1}. {exec} +
  • + ))} +
+
+ )} + + {/* Help Section */} +
+
+

Special Characters

+
    +
  • * - Any value (every)
  • +
  • , - List of values (e.g., 1,15,30)
  • +
  • - - Range of values (e.g., 9-17)
  • +
  • / - Step values (e.g., */5)
  • +
+
+
+

Supported Aliases

+
    +
  • @yearly or @annually - Once a year (Jan 1, 00:00)
  • +
  • @monthly - Once a month (1st, 00:00)
  • +
  • @weekly - Once a week (Sunday, 00:00)
  • +
  • @daily or @midnight - Once a day (00:00)
  • +
  • @hourly - Once an hour (minute 0)
  • +
+
+
+
+
+
+
+ ); +}; + +export default CrontabExplainer; diff --git a/app/libs/constants.tsx b/app/libs/constants.tsx index 95fec36..ef6f8f2 100644 --- a/app/libs/constants.tsx +++ b/app/libs/constants.tsx @@ -70,6 +70,7 @@ import CmykToRgbConverter from '../components/developmentToolsComponent/cmykToRg import ColorInvertor from '../components/developmentToolsComponent/colorInvertor'; import ColorPickerTool from '../components/developmentToolsComponent/colorPickerTool'; import CrontabGenerator from '../components/developmentToolsComponent/crontabGenerator'; +import CrontabExplainer from '../components/developmentToolsComponent/crontabExplainer'; import CssMinify from '../components/developmentToolsComponent/cssMinify'; import CssPrettify from '../components/developmentToolsComponent/cssPrettify'; import CssToLess from '../components/developmentToolsComponent/cssToLess'; @@ -1551,6 +1552,11 @@ export const developmentToolsCategoryContent: any = { title: 'Crontab Generator', description: 'Generate Crontab.', }, + { + url: '/crontab-explainer', + title: 'Crontab Explainer', + description: 'Explain Crontab expressions in plain English.', + }, ], Category169: [ { @@ -1796,6 +1802,7 @@ export const PATHS = { CSS_TO_SASS: '/css-to-sass', CSS_TO_LESS: '/css-to-less', CRONTAB_GENERATOR: '/crontab-generator', + CRONTAB_EXPLAINER: '/crontab-explainer', MORSE_CODE_TRANSLATOR: '/morse-code-translator', CURL_TO_CODE_CONVERTER: '/curl-to-code-converter', }; @@ -2481,6 +2488,10 @@ export const developmentToolsRoutes = [ path: PATHS.CRONTAB_GENERATOR, component: , }, + { + path: PATHS.CRONTAB_EXPLAINER, + component: , + }, { path: PATHS.MORSE_CODE_TRANSLATOR, component: , diff --git a/app/libs/developmentToolsConstant.tsx b/app/libs/developmentToolsConstant.tsx index fb303c8..fe3dfae 100644 --- a/app/libs/developmentToolsConstant.tsx +++ b/app/libs/developmentToolsConstant.tsx @@ -17264,6 +17264,88 @@ family[1]: "Beth"`, og_image: '/images/og-images/Cover.png', }, }, + [`crontab-explainer`]: { + hero_section: { + title: 'Crontab Explainer', + description: + 'Decode cron expressions into plain English and preview upcoming execution times.', + }, + development_tools_list: [ + { tool: 'Crontab Generator', url: PATHS.CRONTAB_GENERATOR }, + { tool: 'Regex Tester', url: PATHS.JAVASCRIPT_REGEX_TESTER }, + ], + development_tools_about_details: { + about_title: 'About Crontab Explainer', + about_description: [ + { + description: + 'The Crontab Explainer takes an existing cron expression and translates it into human-readable language. It supports standard 5-field cron syntax and common aliases like @hourly, @daily, @weekly, @monthly, and @yearly.', + }, + { + description: + 'This tool is perfect for understanding cron expressions you find in existing codebases, verifying that your scheduled jobs run at the expected times, and learning how cron syntax works.', + }, + ], + }, + development_tools_steps_guide: { + guide_title: 'How to Use', + guide_description: 'Explain a cron expression:', + steps: [ + { + step_key: 'Step 1:', + step_title: 'Enter expression', + step_description: + 'Paste or type a 5-field cron expression (e.g., */5 * * * *) or use an alias like @hourly.', + }, + { + step_key: 'Step 2:', + step_title: 'Read explanation', + step_description: + 'View the human-readable description of when the job will run.', + }, + { + step_key: 'Step 3:', + step_title: 'Preview executions', + step_description: + 'Check the next 5 scheduled execution times to verify the schedule.', + }, + ], + }, + development_tools_how_use: { + how_use_title: 'Use Cases', + how_use_description: 'When this tool helps:', + point: [ + { + title: 'Understanding existing cron jobs', + description: 'Decode cron expressions found in legacy code or server configurations.', + }, + { + title: 'Verifying schedules', + description: 'Confirm that a cron expression runs at the expected times before deployment.', + }, + { + title: 'Learning cron syntax', + description: 'Experiment with different expressions to understand how cron scheduling works.', + }, + { + title: 'Debugging scheduled tasks', + description: 'Identify why a job is not running as expected by checking its schedule.', + }, + { + title: 'Documentation', + description: 'Generate plain English descriptions for cron jobs in your documentation.', + }, + ], + }, + meta_data: { + meta_title: 'Crontab Explainer - Decode Cron Expressions', + meta_description: + 'Translate cron expressions into plain English and preview next execution times. Supports standard syntax and aliases like @hourly, @daily, @weekly.', + og_title: 'Crontab Explainer - Developer Utility', + og_description: 'Understand cron expressions with human-readable explanations and execution previews.', + og_image: '/images/og-images/Cover.png', + }, + }, [`morse-code-translator`]: { hero_section: { title: 'Morse Code Translator', From 51f876f69bc14a57f15114ea7b079802cc9860ac Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:22:22 +0530 Subject: [PATCH 19/24] fix Day of Month and Day of Week logic --- .../crontabExplainer.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index 0cceffc..b47fb6e 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -170,13 +170,19 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { const month = current.getMonth() + 1; const dow = current.getDay(); - if ( - matchField(minute, minuteField, 0, 59) && - matchField(hour, hourField, 0, 23) && - matchField(dom, domField, 1, 31) && - matchField(month, monthField, 1, 12) && - matchField(dow, dowField, 0, 6) - ) { + const minuteMatch = matchField(minute, minuteField, 0, 59); + const hourMatch = matchField(hour, hourField, 0, 23); + const domMatch = matchField(dom, domField, 1, 31); + const monthMatch = matchField(month, monthField, 1, 12); + const dowMatch = matchField(dow, dowField, 0, 6); + + // In standard 5-field cron, DOM and DOW are OR'd when both are restricted. + const hasDomConstraint = domField !== "*"; + const hasDowConstraint = dowField !== "*"; + const dayMatch = + hasDomConstraint && hasDowConstraint ? domMatch || dowMatch : domMatch && dowMatch; + + if (minuteMatch && hourMatch && monthMatch && dayMatch) { executions.push(current.toLocaleString()); } } From 1536cc259a8d7a7a0f3c46fd37a8957841c6359d Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:28:00 +0530 Subject: [PATCH 20/24] fix Next 5 executions preview --- .../crontabExplainer.tsx | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index b47fb6e..7d4ba3c 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -156,34 +156,56 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { const now = new Date(); const executions: string[] = []; - let current = new Date(now); - current.setSeconds(0); - current.setMilliseconds(0); - - // Simple implementation - check next 10000 minutes - for (let i = 0; i < 10000 && executions.length < count; i++) { - current = new Date(current.getTime() + 60000); // Add 1 minute - - const minute = current.getMinutes(); - const hour = current.getHours(); - const dom = current.getDate(); - const month = current.getMonth() + 1; - const dow = current.getDay(); - - const minuteMatch = matchField(minute, minuteField, 0, 59); - const hourMatch = matchField(hour, hourField, 0, 23); - const domMatch = matchField(dom, domField, 1, 31); + + const minuteValues = getMatchingValues(minuteField, 0, 59); + const hourValues = getMatchingValues(hourField, 0, 23); + if (minuteValues.length === 0 || hourValues.length === 0) return []; + + const hasDomConstraint = domField !== "*"; + const hasDowConstraint = dowField !== "*"; + + // 30 years covers sparse valid schedules (e.g. leap-day based) for at least 5 occurrences. + const maxLookaheadDays = 366 * 30; + const dayCursor = new Date(now); + dayCursor.setHours(0, 0, 0, 0); + + for (let dayOffset = 0; dayOffset <= maxLookaheadDays && executions.length < count; dayOffset++) { + if (dayOffset > 0) { + dayCursor.setDate(dayCursor.getDate() + 1); + } + + const dom = dayCursor.getDate(); + const month = dayCursor.getMonth() + 1; + const dow = dayCursor.getDay(); + const monthMatch = matchField(month, monthField, 1, 12); - const dowMatch = matchField(dow, dowField, 0, 6); + if (!monthMatch) continue; - // In standard 5-field cron, DOM and DOW are OR'd when both are restricted. - const hasDomConstraint = domField !== "*"; - const hasDowConstraint = dowField !== "*"; + const domMatch = matchField(dom, domField, 1, 31); + const dowMatch = matchField(dow, dowField, 0, 6); const dayMatch = hasDomConstraint && hasDowConstraint ? domMatch || dowMatch : domMatch && dowMatch; - if (minuteMatch && hourMatch && monthMatch && dayMatch) { - executions.push(current.toLocaleString()); + if (!dayMatch) continue; + + for (const hour of hourValues) { + for (const minute of minuteValues) { + const candidate = new Date( + dayCursor.getFullYear(), + dayCursor.getMonth(), + dayCursor.getDate(), + hour, + minute, + 0, + 0 + ); + + if (candidate <= now) continue; + + executions.push(candidate.toLocaleString()); + if (executions.length >= count) break; + } + if (executions.length >= count) break; } } @@ -193,6 +215,16 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { } }; +const getMatchingValues = (field: string, min: number, max: number): number[] => { + const values: number[] = []; + for (let value = min; value <= max; value++) { + if (matchField(value, field, min, max)) { + values.push(value); + } + } + return values; +}; + const matchField = (value: number, field: string, min: number, max: number): boolean => { if (field === "*") return true; From 4b2a68556708a991d26e20c0ce038cad697c4dcf Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:48:06 +0530 Subject: [PATCH 21/24] add / fix step parsing --- .../crontabExplainer.tsx | 142 ++++++++++++++---- 1 file changed, 111 insertions(+), 31 deletions(-) diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index 7d4ba3c..3a0095e 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -18,6 +18,26 @@ const ALIASES: Record = { const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +type CronFieldType = "minute" | "hour" | "dom" | "month" | "dow"; + +const FIELD_SPECS: Record }> = { + minute: { min: 0, max: 59 }, + hour: { min: 0, max: 23 }, + dom: { min: 1, max: 31 }, + month: { + min: 1, + max: 12, + names: { + jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, + jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, + }, + }, + dow: { + min: 0, + max: 6, + names: { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }, + }, +}; // Parse a cron field into human-readable text const parseField = (field: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { @@ -97,7 +117,7 @@ const explainCron = (cronExpr: string): string => { } const parts = cronExpr.trim().split(/\s+/); - if (parts.length !== 5) { + if (!isValidCronExpression(parts)) { return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; } @@ -150,15 +170,15 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { const expr = ALIASES[trimmed] || cronExpr.trim(); const parts = expr.split(/\s+/); - if (parts.length !== 5) return []; + if (!isValidCronExpression(parts)) return []; const [minuteField, hourField, domField, monthField, dowField] = parts; const now = new Date(); const executions: string[] = []; - const minuteValues = getMatchingValues(minuteField, 0, 59); - const hourValues = getMatchingValues(hourField, 0, 23); + const minuteValues = getMatchingValues(minuteField, "minute"); + const hourValues = getMatchingValues(hourField, "hour"); if (minuteValues.length === 0 || hourValues.length === 0) return []; const hasDomConstraint = domField !== "*"; @@ -178,11 +198,11 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { const month = dayCursor.getMonth() + 1; const dow = dayCursor.getDay(); - const monthMatch = matchField(month, monthField, 1, 12); + const monthMatch = matchField(month, monthField, "month"); if (!monthMatch) continue; - const domMatch = matchField(dom, domField, 1, 31); - const dowMatch = matchField(dow, dowField, 0, 6); + const domMatch = matchField(dom, domField, "dom"); + const dowMatch = matchField(dow, dowField, "dow"); const dayMatch = hasDomConstraint && hasDowConstraint ? domMatch || dowMatch : domMatch && dowMatch; @@ -215,44 +235,104 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { } }; -const getMatchingValues = (field: string, min: number, max: number): number[] => { +const getMatchingValues = (field: string, type: CronFieldType): number[] => { + const { min, max } = FIELD_SPECS[type]; const values: number[] = []; for (let value = min; value <= max; value++) { - if (matchField(value, field, min, max)) { + if (matchField(value, field, type)) { values.push(value); } } return values; }; -const matchField = (value: number, field: string, min: number, max: number): boolean => { - if (field === "*") return true; +const isValidCronExpression = (parts: string[]): boolean => { + if (parts.length !== 5) return false; + const [minute, hour, dom, month, dow] = parts; + return ( + isValidField(minute, "minute") && + isValidField(hour, "hour") && + isValidField(dom, "dom") && + isValidField(month, "month") && + isValidField(dow, "dow") + ); +}; - // Handle step values - if (field.includes("/")) { - const [base, step] = field.split("/"); - const stepNum = parseInt(step); - if (base === "*") { - return value % stepNum === 0; - } - const baseNum = parseInt(base); - return value >= baseNum && (value - baseNum) % stepNum === 0; +const isValidField = (field: string, type: CronFieldType): boolean => { + if (!field || field.trim() === "") return false; + const parts = field.split(","); + if (parts.some((part) => part.trim() === "")) return false; + return parts.every((part) => isValidFieldPart(part.trim(), type)); +}; + +const isValidFieldPart = (part: string, type: CronFieldType): boolean => { + return parseFieldPartSpec(part, type) !== null; +}; + +type FieldPartSpec = { + start: number; + end: number; + step: number; +}; + +const parseFieldPartSpec = (part: string, type: CronFieldType): FieldPartSpec | null => { + const [base, step] = part.split("/"); + if (!base || part.split("/").length > 2) return null; + + let stepValue = 1; + if (step !== undefined) { + if (!/^\d+$/.test(step)) return null; + stepValue = parseInt(step, 10); + if (stepValue <= 0) return null; } - // Handle ranges - if (field.includes("-") && !field.includes(",")) { - const [start, end] = field.split("-").map((v) => parseInt(v)); - return value >= start && value <= end; + const spec = FIELD_SPECS[type]; + + if (base === "*") { + return { start: spec.min, end: spec.max, step: stepValue }; } - // Handle lists - if (field.includes(",")) { - const values = field.split(",").map((v) => parseInt(v.trim())); - return values.includes(value); + if (base.includes("-")) { + const [startRaw, endRaw] = base.split("-"); + if (!startRaw || !endRaw || base.split("-").length > 2) return null; + const start = parseFieldValue(startRaw, type); + const end = parseFieldValue(endRaw, type); + if (start === null || end === null) return null; + if (start > end) return null; + return { start, end, step: stepValue }; } - // Single value - return value === parseInt(field); + const start = parseFieldValue(base, type); + if (start === null) return null; + + // x/n means from x through field max, stepping by n. + return { start, end: spec.max, step: stepValue }; +}; + +const parseFieldValue = (raw: string, type: CronFieldType): number | null => { + const token = raw.trim().toLowerCase(); + const spec = FIELD_SPECS[type]; + + if (spec.names && token in spec.names) { + return spec.names[token]; + } + + if (!/^\d+$/.test(token)) return null; + const value = parseInt(token, 10); + if (value < spec.min || value > spec.max) return null; + return value; +}; + +const matchField = (value: number, field: string, type: CronFieldType): boolean => { + if (!isValidField(field, type)) return false; + return field.split(",").some((part) => matchFieldPart(value, part.trim(), type)); +}; + +const matchFieldPart = (value: number, part: string, type: CronFieldType): boolean => { + const spec = parseFieldPartSpec(part, type); + if (!spec) return false; + if (value < spec.start || value > spec.end) return false; + return (value - spec.start) % spec.step === 0; }; const CrontabExplainer = () => { @@ -274,7 +354,7 @@ const CrontabExplainer = () => { const trimmed = cronInput.trim().toLowerCase(); if (ALIASES[trimmed]) return true; const parts = cronInput.trim().split(/\s+/); - return parts.length === 5; + return isValidCronExpression(parts); }, [cronInput]); const examples = [ From 019b762656770010f5b11fc43ea017581e5bff99 Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:54:18 +0530 Subject: [PATCH 22/24] add/fix utc local preview --- .../crontabExplainer.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index 3a0095e..8034389 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -164,7 +164,7 @@ const explainCron = (cronExpr: string): string => { }; // Calculate next N executions -const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { +const getNextExecutions = (cronExpr: string, count: number = 5): Date[] => { try { const trimmed = cronExpr.trim().toLowerCase(); const expr = ALIASES[trimmed] || cronExpr.trim(); @@ -175,7 +175,7 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { const [minuteField, hourField, domField, monthField, dowField] = parts; const now = new Date(); - const executions: string[] = []; + const executions: Date[] = []; const minuteValues = getMatchingValues(minuteField, "minute"); const hourValues = getMatchingValues(hourField, "hour"); @@ -222,7 +222,7 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { if (candidate <= now) continue; - executions.push(candidate.toLocaleString()); + executions.push(candidate); if (executions.length >= count) break; } if (executions.length >= count) break; @@ -235,6 +235,13 @@ const getNextExecutions = (cronExpr: string, count: number = 5): string[] => { } }; +const formatExecution = (date: Date, timezone: "local" | "utc"): string => { + if (timezone === "utc") { + return date.toUTCString(); + } + return date.toLocaleString(); +}; + const getMatchingValues = (field: string, type: CronFieldType): number[] => { const { min, max } = FIELD_SPECS[type]; const values: number[] = []; @@ -338,6 +345,7 @@ const matchFieldPart = (value: number, part: string, type: CronFieldType): boole const CrontabExplainer = () => { const [cronInput, setCronInput] = useState(""); const [showExecutions, setShowExecutions] = useState(true); + const [previewTimezone, setPreviewTimezone] = useState<"local" | "utc">("local"); const explanation = useMemo(() => { if (!cronInput.trim()) return ""; @@ -447,13 +455,24 @@ const CrontabExplainer = () => { Show preview +
+ Time context: + +
    {nextExecutions.map((exec, idx) => (
  • - {idx + 1}. {exec} + {idx + 1}. {formatExecution(exec, previewTimezone)}
  • ))}
From 48aaa0acd60aaa5c240d13e22b30c3fb98f3b4de Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:00:50 +0530 Subject: [PATCH 23/24] fix ux issue with preview --- .../crontabExplainer.tsx | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index 8034389..b2231ae 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -441,7 +441,7 @@ const CrontabExplainer = () => { )} {/* Next Executions */} - {nextExecutions.length > 0 && ( + {cronInput.trim() && isValid && (
Next 5 Executions
@@ -466,16 +466,28 @@ const CrontabExplainer = () => {
-
    - {nextExecutions.map((exec, idx) => ( -
  • - {idx + 1}. {formatExecution(exec, previewTimezone)} -
  • - ))} -
+ {showExecutions ? ( + nextExecutions.length > 0 ? ( +
    + {nextExecutions.map((exec, idx) => ( +
  • + {idx + 1}. {formatExecution(exec, previewTimezone)} +
  • + ))} +
+ ) : ( +
+ No upcoming executions found within the current preview horizon. +
+ ) + ) : ( +
+ Preview is disabled. +
+ )}
)} From 1ff00f0debc867e954e5c778e981f323eca05261 Mon Sep 17 00:00:00 2001 From: Madhav Majumdar <161720210+madhav2348@users.noreply.github.com> Date: Sat, 7 Mar 2026 11:44:56 +0530 Subject: [PATCH 24/24] fix duplication --- .../crontabExplainer.tsx | 254 +---------------- .../crontabGenerator.tsx | 13 +- app/libs/cron.ts | 261 ++++++++++++++++++ 3 files changed, 277 insertions(+), 251 deletions(-) create mode 100644 app/libs/cron.ts diff --git a/app/components/developmentToolsComponent/crontabExplainer.tsx b/app/components/developmentToolsComponent/crontabExplainer.tsx index b2231ae..073173e 100644 --- a/app/components/developmentToolsComponent/crontabExplainer.tsx +++ b/app/components/developmentToolsComponent/crontabExplainer.tsx @@ -1,173 +1,24 @@ "use client"; import React, { useMemo, useState } from "react"; +import { + CRON_ALIASES, + CRON_FIELD_SPECS, + CronFieldType, + humanizeCronExpression, + isValidCronExpression, + isValidField, + parseFieldPartSpec, + resolveCronAlias, +} from "../../libs/cron"; const Cmd = ({ children }: { children: string }) => ( {children} ); -// Cron aliases mapping -const ALIASES: Record = { - "@yearly": "0 0 1 1 *", - "@annually": "0 0 1 1 *", - "@monthly": "0 0 1 * *", - "@weekly": "0 0 * * 0", - "@daily": "0 0 * * *", - "@midnight": "0 0 * * *", - "@hourly": "0 * * * *", -}; - -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; -const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -type CronFieldType = "minute" | "hour" | "dom" | "month" | "dow"; - -const FIELD_SPECS: Record }> = { - minute: { min: 0, max: 59 }, - hour: { min: 0, max: 23 }, - dom: { min: 1, max: 31 }, - month: { - min: 1, - max: 12, - names: { - jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, - jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, - }, - }, - dow: { - min: 0, - max: 6, - names: { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }, - }, -}; - -// Parse a cron field into human-readable text -const parseField = (field: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { - if (field === "*") { - if (type === "minute") return "every minute"; - if (type === "hour") return "every hour"; - if (type === "dom") return "every day"; - if (type === "month") return "every month"; - return "every day of week"; - } - - // Handle step values (*/n or x/n) - if (field.includes("/")) { - const [base, step] = field.split("/"); - const stepNum = parseInt(step); - if (base === "*") { - if (type === "minute") return `every ${stepNum} minute${stepNum > 1 ? "s" : ""}`; - if (type === "hour") return `every ${stepNum} hour${stepNum > 1 ? "s" : ""}`; - if (type === "dom") return `every ${stepNum} day${stepNum > 1 ? "s" : ""}`; - if (type === "month") return `every ${stepNum} month${stepNum > 1 ? "s" : ""}`; - return `every ${stepNum} day${stepNum > 1 ? "s" : ""} of week`; - } - return `every ${stepNum} starting from ${base}`; - } - - // Handle ranges (x-y) - if (field.includes("-") && !field.includes(",")) { - const [start, end] = field.split("-"); - const startLabel = formatValue(start, type); - const endLabel = formatValue(end, type); - return `from ${startLabel} to ${endLabel}`; - } - - // Handle lists (x,y,z) - if (field.includes(",")) { - const values = field.split(",").map((v) => formatValue(v.trim(), type)); - if (values.length > 3) { - return `at ${values.slice(0, 3).join(", ")}, and ${values.length - 3} more`; - } - return `at ${values.join(", ")}`; - } - - // Single value - return `at ${formatValue(field, type)}`; -}; - -const formatValue = (value: string, type: "minute" | "hour" | "dom" | "month" | "dow"): string => { - const num = parseInt(value); - if (isNaN(num)) { - // Handle month/day names - if (type === "month") { - const monthIndex = MONTHS.findIndex((m) => m.toLowerCase() === value.toLowerCase()); - return monthIndex >= 0 ? MONTHS[monthIndex] : value; - } - if (type === "dow") { - const dowIndex = DOW.findIndex((d) => d.toLowerCase() === value.toLowerCase()); - return dowIndex >= 0 ? DOW[dowIndex] : value; - } - return value; - } - - if (type === "month" && num >= 1 && num <= 12) return MONTHS[num - 1]; - if (type === "dow" && num >= 0 && num <= 6) return DOW[num]; - if (type === "hour") return `${num.toString().padStart(2, "0")}:00`; - if (type === "minute") return `minute ${num}`; - return value; -}; - -// Generate human-readable explanation -const explainCron = (cronExpr: string): string => { - const trimmed = cronExpr.trim().toLowerCase(); - - // Check for aliases - if (ALIASES[trimmed]) { - const aliasName = trimmed.substring(1); // Remove @ - return `Runs ${aliasName} (${ALIASES[trimmed]})`; - } - - const parts = cronExpr.trim().split(/\s+/); - if (!isValidCronExpression(parts)) { - return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; - } - - const [minute, hour, dom, month, dow] = parts; - - const minuteText = parseField(minute, "minute"); - const hourText = parseField(hour, "hour"); - const domText = parseField(dom, "dom"); - const monthText = parseField(month, "month"); - const dowText = parseField(dow, "dow"); - - // Build natural sentence - let explanation = "Runs "; - - // Time part - if (minute === "*" && hour === "*") { - explanation += "every minute"; - } else if (minute !== "*" && hour === "*") { - explanation += `${minuteText} of every hour`; - } else if (minute === "*" && hour !== "*") { - explanation += `every minute ${hourText}`; - } else { - explanation += `at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; - } - - // Day constraints - const hasDom = dom !== "*"; - const hasDow = dow !== "*"; - - if (hasDom && hasDow) { - explanation += `, ${domText} of the month OR ${dowText}`; - } else if (hasDom) { - explanation += `, ${domText} of the month`; - } else if (hasDow) { - explanation += `, ${dowText}`; - } - - // Month constraint - if (month !== "*") { - explanation += `, ${monthText}`; - } - - return explanation; -}; - // Calculate next N executions const getNextExecutions = (cronExpr: string, count: number = 5): Date[] => { try { - const trimmed = cronExpr.trim().toLowerCase(); - const expr = ALIASES[trimmed] || cronExpr.trim(); + const expr = resolveCronAlias(cronExpr); const parts = expr.split(/\s+/); if (!isValidCronExpression(parts)) return []; @@ -243,7 +94,7 @@ const formatExecution = (date: Date, timezone: "local" | "utc"): string => { }; const getMatchingValues = (field: string, type: CronFieldType): number[] => { - const { min, max } = FIELD_SPECS[type]; + const { min, max } = CRON_FIELD_SPECS[type]; const values: number[] = []; for (let value = min; value <= max; value++) { if (matchField(value, field, type)) { @@ -253,83 +104,6 @@ const getMatchingValues = (field: string, type: CronFieldType): number[] => { return values; }; -const isValidCronExpression = (parts: string[]): boolean => { - if (parts.length !== 5) return false; - const [minute, hour, dom, month, dow] = parts; - return ( - isValidField(minute, "minute") && - isValidField(hour, "hour") && - isValidField(dom, "dom") && - isValidField(month, "month") && - isValidField(dow, "dow") - ); -}; - -const isValidField = (field: string, type: CronFieldType): boolean => { - if (!field || field.trim() === "") return false; - const parts = field.split(","); - if (parts.some((part) => part.trim() === "")) return false; - return parts.every((part) => isValidFieldPart(part.trim(), type)); -}; - -const isValidFieldPart = (part: string, type: CronFieldType): boolean => { - return parseFieldPartSpec(part, type) !== null; -}; - -type FieldPartSpec = { - start: number; - end: number; - step: number; -}; - -const parseFieldPartSpec = (part: string, type: CronFieldType): FieldPartSpec | null => { - const [base, step] = part.split("/"); - if (!base || part.split("/").length > 2) return null; - - let stepValue = 1; - if (step !== undefined) { - if (!/^\d+$/.test(step)) return null; - stepValue = parseInt(step, 10); - if (stepValue <= 0) return null; - } - - const spec = FIELD_SPECS[type]; - - if (base === "*") { - return { start: spec.min, end: spec.max, step: stepValue }; - } - - if (base.includes("-")) { - const [startRaw, endRaw] = base.split("-"); - if (!startRaw || !endRaw || base.split("-").length > 2) return null; - const start = parseFieldValue(startRaw, type); - const end = parseFieldValue(endRaw, type); - if (start === null || end === null) return null; - if (start > end) return null; - return { start, end, step: stepValue }; - } - - const start = parseFieldValue(base, type); - if (start === null) return null; - - // x/n means from x through field max, stepping by n. - return { start, end: spec.max, step: stepValue }; -}; - -const parseFieldValue = (raw: string, type: CronFieldType): number | null => { - const token = raw.trim().toLowerCase(); - const spec = FIELD_SPECS[type]; - - if (spec.names && token in spec.names) { - return spec.names[token]; - } - - if (!/^\d+$/.test(token)) return null; - const value = parseInt(token, 10); - if (value < spec.min || value > spec.max) return null; - return value; -}; - const matchField = (value: number, field: string, type: CronFieldType): boolean => { if (!isValidField(field, type)) return false; return field.split(",").some((part) => matchFieldPart(value, part.trim(), type)); @@ -349,7 +123,7 @@ const CrontabExplainer = () => { const explanation = useMemo(() => { if (!cronInput.trim()) return ""; - return explainCron(cronInput); + return humanizeCronExpression(cronInput); }, [cronInput]); const nextExecutions = useMemo(() => { @@ -360,7 +134,7 @@ const CrontabExplainer = () => { const isValid = useMemo(() => { if (!cronInput.trim()) return true; const trimmed = cronInput.trim().toLowerCase(); - if (ALIASES[trimmed]) return true; + if (CRON_ALIASES[trimmed]) return true; const parts = cronInput.trim().split(/\s+/); return isValidCronExpression(parts); }, [cronInput]); diff --git a/app/components/developmentToolsComponent/crontabGenerator.tsx b/app/components/developmentToolsComponent/crontabGenerator.tsx index 3e37b26..66a7592 100644 --- a/app/components/developmentToolsComponent/crontabGenerator.tsx +++ b/app/components/developmentToolsComponent/crontabGenerator.tsx @@ -1,5 +1,6 @@ "use client"; import React, { useMemo, useState } from "react"; +import { humanizeCronFields } from "../../libs/cron"; type Field = "minute" | "hour" | "dom" | "month" | "dow"; @@ -56,16 +57,6 @@ const fieldToCron = (f: FieldState): string => { } }; -const humanize = (m: FieldState, h: FieldState, dom: FieldState, mo: FieldState, dow: FieldState): string => { - const map = (f: FieldState, name: string) => { - if (f.mode === "every") return `every ${name}`; - if (f.mode === "specific") return `${name} ${f.values}`; - if (f.mode === "range") return `${name} ${f.start}-${f.endOrStep}`; - return `${name} every ${f.endOrStep || 1} starting at ${f.start || 0}`; - }; - return `Runs at ${map(m, "minute")}, ${map(h, "hour")}, ${map(dom, "day")}, ${map(mo, "month")}, ${map(dow, "weekday")}`; -}; - const Presets: { label: string; cron: string }[] = [ { label: "Every minute", cron: "* * * * *" }, { label: "Every 5 minutes", cron: "*/5 * * * *" }, @@ -214,7 +205,7 @@ const CrontabGenerator = () => { return [fieldToCron(minute), fieldToCron(hour), fieldToCron(dom), fieldToCron(month), fieldToCron(dow)].join(" "); }, [minute, hour, dom, month, dow]); - const description = useMemo(() => humanize(minute, hour, dom, month, dow), [minute, hour, dom, month, dow]); + const description = useMemo(() => humanizeCronFields(minute, hour, dom, month, dow), [minute, hour, dom, month, dow]); const applyPreset = (expr: string) => { const [m, h, d, mo, dw] = expr.split(" "); diff --git a/app/libs/cron.ts b/app/libs/cron.ts new file mode 100644 index 0000000..441ee7a --- /dev/null +++ b/app/libs/cron.ts @@ -0,0 +1,261 @@ +export const CRON_ALIASES: Record = { + "@yearly": "0 0 1 1 *", + "@annually": "0 0 1 1 *", + "@monthly": "0 0 1 * *", + "@weekly": "0 0 * * 0", + "@daily": "0 0 * * *", + "@midnight": "0 0 * * *", + "@hourly": "0 * * * *", +}; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +export type CronFieldType = "minute" | "hour" | "dom" | "month" | "dow"; + +export const CRON_FIELD_SPECS: Record }> = { + minute: { min: 0, max: 59 }, + hour: { min: 0, max: 23 }, + dom: { min: 1, max: 31 }, + month: { + min: 1, + max: 12, + names: { + jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, + jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, + }, + }, + dow: { + min: 0, + max: 6, + names: { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }, + }, +}; + +export type FieldPartSpec = { + start: number; + end: number; + step: number; +}; + +export type CronHumanizeFieldState = { + mode: "every" | "specific" | "range" | "interval"; + values: string; + start?: string; + endOrStep?: string; +}; + +export const resolveCronAlias = (cronExpr: string): string => { + const trimmed = cronExpr.trim().toLowerCase(); + return CRON_ALIASES[trimmed] || cronExpr.trim(); +}; + +const formatValue = (value: string, type: CronFieldType): string => { + const num = parseInt(value, 10); + if (Number.isNaN(num)) { + if (type === "month") { + const monthIndex = MONTHS.findIndex((m) => m.toLowerCase() === value.toLowerCase()); + return monthIndex >= 0 ? MONTHS[monthIndex] : value; + } + if (type === "dow") { + const dowIndex = DOW.findIndex((d) => d.toLowerCase() === value.toLowerCase()); + return dowIndex >= 0 ? DOW[dowIndex] : value; + } + return value; + } + + if (type === "month" && num >= 1 && num <= 12) return MONTHS[num - 1]; + if (type === "dow" && num >= 0 && num <= 6) return DOW[num]; + if (type === "hour") return `${num.toString().padStart(2, "0")}:00`; + if (type === "minute") return `minute ${num}`; + return value; +}; + +const parseField = (field: string, type: CronFieldType): string => { + if (field === "*") { + if (type === "minute") return "every minute"; + if (type === "hour") return "every hour"; + if (type === "dom") return "every day"; + if (type === "month") return "every month"; + return "every day of week"; + } + + if (field.includes("/")) { + const [base, step] = field.split("/"); + const stepNum = parseInt(step, 10); + if (base === "*") { + if (type === "minute") return `every ${stepNum} minute${stepNum > 1 ? "s" : ""}`; + if (type === "hour") return `every ${stepNum} hour${stepNum > 1 ? "s" : ""}`; + if (type === "dom") return `every ${stepNum} day${stepNum > 1 ? "s" : ""}`; + if (type === "month") return `every ${stepNum} month${stepNum > 1 ? "s" : ""}`; + return `every ${stepNum} day${stepNum > 1 ? "s" : ""} of week`; + } + return `every ${stepNum} starting from ${base}`; + } + + if (field.includes("-") && !field.includes(",")) { + const [start, end] = field.split("-"); + return `from ${formatValue(start, type)} to ${formatValue(end, type)}`; + } + + if (field.includes(",")) { + const values = field.split(",").map((v) => formatValue(v.trim(), type)); + if (values.length > 3) { + return `at ${values.slice(0, 3).join(", ")}, and ${values.length - 3} more`; + } + return `at ${values.join(", ")}`; + } + + return `at ${formatValue(field, type)}`; +}; + +export const parseFieldValue = (raw: string, type: CronFieldType): number | null => { + const token = raw.trim().toLowerCase(); + const spec = CRON_FIELD_SPECS[type]; + + if (spec.names && token in spec.names) return spec.names[token]; + if (!/^\d+$/.test(token)) return null; + + const value = parseInt(token, 10); + if (value < spec.min || value > spec.max) return null; + return value; +}; + +export const parseFieldPartSpec = (part: string, type: CronFieldType): FieldPartSpec | null => { + const [base, step] = part.split("/"); + if (!base || part.split("/").length > 2) return null; + + let stepValue = 1; + if (step !== undefined) { + if (!/^\d+$/.test(step)) return null; + stepValue = parseInt(step, 10); + if (stepValue <= 0) return null; + } + + const spec = CRON_FIELD_SPECS[type]; + if (base === "*") return { start: spec.min, end: spec.max, step: stepValue }; + + if (base.includes("-")) { + const [startRaw, endRaw] = base.split("-"); + if (!startRaw || !endRaw || base.split("-").length > 2) return null; + const start = parseFieldValue(startRaw, type); + const end = parseFieldValue(endRaw, type); + if (start === null || end === null || start > end) return null; + return { start, end, step: stepValue }; + } + + const start = parseFieldValue(base, type); + if (start === null) return null; + return { start, end: spec.max, step: stepValue }; +}; + +export const isValidField = (field: string, type: CronFieldType): boolean => { + if (!field || field.trim() === "") return false; + const parts = field.split(","); + if (parts.some((part) => part.trim() === "")) return false; + return parts.every((part) => parseFieldPartSpec(part.trim(), type) !== null); +}; + +export const isValidCronExpression = (parts: string[]): boolean => { + if (parts.length !== 5) return false; + const [minute, hour, dom, month, dow] = parts; + return ( + isValidField(minute, "minute") && + isValidField(hour, "hour") && + isValidField(dom, "dom") && + isValidField(month, "month") && + isValidField(dow, "dow") + ); +}; + +export const explainCronExpression = (cronExpr: string): string => { + const trimmed = cronExpr.trim().toLowerCase(); + if (CRON_ALIASES[trimmed]) { + return `Runs ${trimmed.substring(1)} (${CRON_ALIASES[trimmed]})`; + } + + const parts = cronExpr.trim().split(/\s+/); + if (!isValidCronExpression(parts)) { + return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; + } + + const [minute, hour, dom, month, dow] = parts; + const minuteText = parseField(minute, "minute"); + const hourText = parseField(hour, "hour"); + const domText = parseField(dom, "dom"); + const monthText = parseField(month, "month"); + const dowText = parseField(dow, "dow"); + + let explanation = "Runs "; + + if (minute === "*" && hour === "*") { + explanation += "every minute"; + } else if (minute !== "*" && hour === "*") { + explanation += `${minuteText} of every hour`; + } else if (minute === "*" && hour !== "*") { + explanation += `every minute ${hourText}`; + } else { + explanation += `at ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; + } + + const hasDom = dom !== "*"; + const hasDow = dow !== "*"; + + if (hasDom && hasDow) explanation += `, ${domText} of the month OR ${dowText}`; + else if (hasDom) explanation += `, ${domText} of the month`; + else if (hasDow) explanation += `, ${dowText}`; + + if (month !== "*") explanation += `, ${monthText}`; + return explanation; +}; + +export const humanizeCronFields = ( + m: CronHumanizeFieldState, + h: CronHumanizeFieldState, + dom: CronHumanizeFieldState, + mo: CronHumanizeFieldState, + dow: CronHumanizeFieldState +): string => { + const map = (f: CronHumanizeFieldState, name: string) => { + if (f.mode === "every") return `every ${name}`; + if (f.mode === "specific") return `${name} ${f.values}`; + if (f.mode === "range") return `${name} ${f.start}-${f.endOrStep}`; + return `${name} every ${f.endOrStep || 1} starting at ${f.start || 0}`; + }; + return `Runs at ${map(m, "minute")}, ${map(h, "hour")}, ${map(dom, "day")}, ${map(mo, "month")}, ${map(dow, "weekday")}`; +}; + +const tokenToHumanizeField = (token: string): CronHumanizeFieldState => { + if (token === "*") return { mode: "every", values: "", start: "", endOrStep: "" }; + if (token.includes("-")) { + const [start, endOrStep] = token.split("-"); + return { mode: "range", values: "", start, endOrStep }; + } + if (token.includes("/")) { + const [start, endOrStep] = token.split("/"); + return { mode: "interval", values: "", start: start === "*" ? "" : start, endOrStep }; + } + return { mode: "specific", values: token, start: "", endOrStep: "" }; +}; + +export const humanizeCronExpression = (cronExpr: string): string => { + const trimmed = cronExpr.trim().toLowerCase(); + if (CRON_ALIASES[trimmed]) { + const aliasName = trimmed.substring(1); + return `Runs ${aliasName} (${CRON_ALIASES[trimmed]})`; + } + + const parts = cronExpr.trim().split(/\s+/); + if (!isValidCronExpression(parts)) { + return "Invalid cron expression. Expected 5 fields: minute hour day month weekday"; + } + + const [m, h, dom, mo, dow] = parts; + return humanizeCronFields( + tokenToHumanizeField(m), + tokenToHumanizeField(h), + tokenToHumanizeField(dom), + tokenToHumanizeField(mo), + tokenToHumanizeField(dow) + ); +};