From 76245edd3438a0d99e3f72525869786783b55ff9 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 14:49:14 -0700 Subject: [PATCH 01/41] feat: add audio extraction tool to convert video files to audio formats (AAC, MP3, WAV) --- src/components/input/ToolAudioInput.tsx | 46 ++++++++ .../extract-audio.service.test.ts | 50 +++++++++ src/pages/tools/audio/extract-audio/index.tsx | 105 ++++++++++++++++++ src/pages/tools/audio/extract-audio/meta.ts | 26 +++++ .../tools/audio/extract-audio/service.ts | 70 ++++++++++++ src/pages/tools/audio/extract-audio/types.ts | 3 + src/pages/tools/audio/index.ts | 2 + src/tools/defineTool.tsx | 3 +- src/tools/index.ts | 13 ++- 9 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 src/components/input/ToolAudioInput.tsx create mode 100644 src/pages/tools/audio/extract-audio/extract-audio.service.test.ts create mode 100644 src/pages/tools/audio/extract-audio/index.tsx create mode 100644 src/pages/tools/audio/extract-audio/meta.ts create mode 100644 src/pages/tools/audio/extract-audio/service.ts create mode 100644 src/pages/tools/audio/extract-audio/types.ts create mode 100644 src/pages/tools/audio/index.ts diff --git a/src/components/input/ToolAudioInput.tsx b/src/components/input/ToolAudioInput.tsx new file mode 100644 index 0000000..c9090a7 --- /dev/null +++ b/src/components/input/ToolAudioInput.tsx @@ -0,0 +1,46 @@ +import React, { useRef } from 'react'; +import { Box, Typography } from '@mui/material'; +import BaseFileInput from './BaseFileInput'; +import { BaseFileInputProps } from './file-input-utils'; + +interface AudioFileInputProps extends Omit { + accept?: string[]; +} + +export default function ToolAudioInput({ + accept = ['audio/*', '.mp3', '.wav', '.aac'], + ...props +}: AudioFileInputProps) { + const audioRef = useRef(null); + + return ( + + {({ preview }) => ( + + {preview ? ( + + )} + + ); +} diff --git a/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts b/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts new file mode 100644 index 0000000..88624b9 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/extract-audio.service.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeAll } from 'vitest'; + +// Mock the service module BEFORE importing it +vi.mock('./service', () => ({ + extractAudioFromVideo: vi.fn(async (input, options) => { + const ext = options.outputFormat; + return new File([new Blob(['audio data'])], `mock_audio.${ext}`, { + type: `audio/${ext}` + }); + }) +})); + +import { extractAudioFromVideo } from './service'; +import { InitialValuesType } from './types'; + +function createMockVideoFile(): File { + return new File(['video data'], 'test.mp4', { type: 'video/mp4' }); +} + +describe('extractAudioFromVideo (mocked)', () => { + let videoFile: File; + + beforeAll(() => { + videoFile = createMockVideoFile(); + }); + + it('should extract audio as AAC', async () => { + const options: InitialValuesType = { outputFormat: 'aac' }; + const audioFile = await extractAudioFromVideo(videoFile, options); + expect(audioFile).toBeInstanceOf(File); + expect(audioFile.name.endsWith('.aac')).toBe(true); + expect(audioFile.type).toBe('audio/aac'); + }); + + it('should extract audio as MP3', async () => { + const options: InitialValuesType = { outputFormat: 'mp3' }; + const audioFile = await extractAudioFromVideo(videoFile, options); + expect(audioFile).toBeInstanceOf(File); + expect(audioFile.name.endsWith('.mp3')).toBe(true); + expect(audioFile.type).toBe('audio/mp3'); + }); + + it('should extract audio as WAV', async () => { + const options: InitialValuesType = { outputFormat: 'wav' }; + const audioFile = await extractAudioFromVideo(videoFile, options); + expect(audioFile).toBeInstanceOf(File); + expect(audioFile.name.endsWith('.wav')).toBe(true); + expect(audioFile.type).toBe('audio/wav'); + }); +}); diff --git a/src/pages/tools/audio/extract-audio/index.tsx b/src/pages/tools/audio/extract-audio/index.tsx new file mode 100644 index 0000000..77e0f48 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/index.tsx @@ -0,0 +1,105 @@ +import { + Box, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent +} from '@mui/material'; +import React, { useState, useEffect, useRef } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { extractAudioFromVideo } from './service'; +import { InitialValuesType } from './types'; +import ToolVideoInput from '@components/input/ToolVideoInput'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import ToolFileResult from '@components/result/ToolFileResult'; +import SelectWithDesc from '@components/options/SelectWithDesc'; + +const initialValues: InitialValuesType = { + outputFormat: 'aac' +}; + +export default function ExtractAudio({ + title, + longDescription +}: ToolComponentProps) { + const [file, setFile] = useState(null); + const [audioFile, setAudioFile] = useState(null); + const [loading, setLoading] = useState(false); + + // Tool Options section for output format + const getGroups: GetGroupsType = ({ + values, + updateField + }) => { + return [ + { + title: 'Output Format', + component: ( + + { + updateField('outputFormat', value.toString()); + }} + options={[ + { label: 'AAC', value: 'aac' }, + { label: 'MP3', value: 'mp3' }, + { label: 'WAV', value: 'wav' } + ]} + description={ + 'Select the format for the audio to be extracted as.' + } + /> + + ) + } + ]; + }; + + // Compute function for ToolContent (no-op, extraction is handled by effect) + const compute = async (values: InitialValuesType, input: File | null) => { + if (!input) return; + try { + setLoading(true); + const audioFileObj = await extractAudioFromVideo(input, values); + await setAudioFile(audioFileObj); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + compute={compute} + toolInfo={{ title: `What is ${title}?`, description: longDescription }} + setInput={setFile} + /> + ); +} diff --git a/src/pages/tools/audio/extract-audio/meta.ts b/src/pages/tools/audio/extract-audio/meta.ts new file mode 100644 index 0000000..a89afe8 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/meta.ts @@ -0,0 +1,26 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('audio', { + name: 'Extract audio', + path: 'extract-audio', + icon: 'mdi:music-note', + description: + 'Extract the audio track from a video file and save it as a separate audio file in your chosen format (AAC, MP3, WAV).', + shortDescription: + 'Extract audio from video files (MP4, MOV, etc.) to AAC, MP3, or WAV.', + keywords: [ + 'extract', + 'audio', + 'video', + 'mp3', + 'aac', + 'wav', + 'audio extraction', + 'media', + 'convert' + ], + longDescription: + 'This tool allows you to extract the audio track from a video file (such as MP4, MOV, AVI, etc.) and save it as a standalone audio file in your preferred format (AAC, MP3, or WAV). Useful for podcasts, music, or any scenario where you need just the audio from a video.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/audio/extract-audio/service.ts b/src/pages/tools/audio/extract-audio/service.ts new file mode 100644 index 0000000..36ec1c2 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/service.ts @@ -0,0 +1,70 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import { InitialValuesType } from './types'; + +const ffmpeg = new FFmpeg(); + +export async function extractAudioFromVideo( + input: File, + options: InitialValuesType +): Promise { + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + const inputName = 'input.mp4'; + await ffmpeg.writeFile(inputName, await fetchFile(input)); + + const configuredOutputAudioFormat = options.outputFormat; + const outputName = `output.${configuredOutputAudioFormat}`; + let args: string[] = ['-i', inputName, '-vn']; + + if (configuredOutputAudioFormat === 'mp3') { + args.push( + '-ar', + '44100', + '-ac', + '2', + '-b:a', + '192k', + '-f', + 'mp3', + outputName + ); + } else if (configuredOutputAudioFormat === 'wav') { + args.push( + '-acodec', + 'pcm_s16le', + '-ar', + '44100', + '-ac', + '2', + '-f', + 'wav', + outputName + ); + } else { + // Default to AAC or copy + args.push('-acodec', 'copy', outputName); + } + + await ffmpeg.exec(args); + + const extractedAudio = await ffmpeg.readFile(outputName); + + return new File( + [ + new Blob([extractedAudio], { + type: `audio/${configuredOutputAudioFormat}` + }) + ], + `${input.name.replace( + /\.[^/.]+$/, + '' + )}_audio.${configuredOutputAudioFormat}`, + { type: `audio/${configuredOutputAudioFormat}` } + ); +} diff --git a/src/pages/tools/audio/extract-audio/types.ts b/src/pages/tools/audio/extract-audio/types.ts new file mode 100644 index 0000000..d6d4f36 --- /dev/null +++ b/src/pages/tools/audio/extract-audio/types.ts @@ -0,0 +1,3 @@ +export type InitialValuesType = { + outputFormat: string; +}; diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts new file mode 100644 index 0000000..40b2828 --- /dev/null +++ b/src/pages/tools/audio/index.ts @@ -0,0 +1,2 @@ +import { tool as audioExtractAudio } from './extract-audio/meta'; +export const audioTools = [audioExtractAudio]; diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index 0ea3935..57a0e0f 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -24,7 +24,8 @@ export type ToolCategory = | 'time' | 'csv' | 'pdf' - | 'image-generic'; + | 'image-generic' + | 'audio'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index 08a2cf8..3ebd631 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,6 +4,7 @@ import { DefinedTool, ToolCategory } from './defineTool'; import { capitalizeFirstLetter } from '../utils/string'; import { numberTools } from '../pages/tools/number'; import { videoTools } from '../pages/tools/video'; +import { audioTools } from 'pages/tools/audio'; import { listTools } from '../pages/tools/list'; import { Entries } from 'type-fest'; import { jsonTools } from '../pages/tools/json'; @@ -23,7 +24,8 @@ const toolCategoriesOrder: ToolCategory[] = [ 'number', 'png', 'time', - 'gif' + 'gif', + 'audio' ]; export const tools: DefinedTool[] = [ ...imageTools, @@ -34,7 +36,8 @@ export const tools: DefinedTool[] = [ ...csvTools, ...videoTools, ...numberTools, - ...timeTools + ...timeTools, + ...audioTools ]; const categoriesConfig: { type: ToolCategory; @@ -115,6 +118,12 @@ const categoriesConfig: { icon: 'material-symbols-light:image-outline-rounded', value: 'Tools for working with pictures – compress, resize, crop, convert to JPG, rotate, remove background and much more.' + }, + { + type: 'audio', + icon: 'rivet-icons:audio', + value: + 'Tools for working with audio – extract audio from video, adjusting audio speed, merging multiple audio files and much more.' } ]; // use for changelogs From a1b929e45ce66fe79cfe52dfe4f7872738627671 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 15:17:14 -0700 Subject: [PATCH 02/41] feat: added audio speed change tool with adjustable speed and output format options --- src/pages/tools/audio/change-speed/index.tsx | 187 ++++++++++++++++++ src/pages/tools/audio/change-speed/meta.ts | 13 ++ src/pages/tools/audio/change-speed/service.ts | 8 + src/pages/tools/audio/change-speed/types.ts | 4 + src/pages/tools/audio/index.ts | 4 +- 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/audio/change-speed/index.tsx create mode 100644 src/pages/tools/audio/change-speed/meta.ts create mode 100644 src/pages/tools/audio/change-speed/service.ts create mode 100644 src/pages/tools/audio/change-speed/types.ts diff --git a/src/pages/tools/audio/change-speed/index.tsx b/src/pages/tools/audio/change-speed/index.tsx new file mode 100644 index 0000000..d995055 --- /dev/null +++ b/src/pages/tools/audio/change-speed/index.tsx @@ -0,0 +1,187 @@ +import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { InitialValuesType } from './types'; +import ToolAudioInput from '@components/input/ToolAudioInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import RadioWithTextField from '@components/options/RadioWithTextField'; + +const initialValues: InitialValuesType = { + newSpeed: 2, + outputFormat: 'mp3' +}; + +const formatOptions = [ + { label: 'MP3', value: 'mp3' }, + { label: 'AAC', value: 'aac' }, + { label: 'WAV', value: 'wav' } +]; + +export default function ChangeSpeed({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + // FFmpeg only supports a tempo between 0.5 and 2.0, so we chain filters + const computeAudioFilter = (speed: number): string => { + if (speed <= 2 && speed >= 0.5) { + return `atempo=${speed}`; + } + const filters: string[] = []; + let remainingSpeed = speed; + while (remainingSpeed > 2.0) { + filters.push('atempo=2.0'); + remainingSpeed /= 2.0; + } + while (remainingSpeed < 0.5) { + filters.push('atempo=0.5'); + remainingSpeed /= 0.5; + } + filters.push(`atempo=${remainingSpeed.toFixed(2)}`); + return filters.join(','); + }; + + const compute = async ( + optionsValues: InitialValuesType, + input: File | null + ) => { + if (!input) return; + const { newSpeed, outputFormat } = optionsValues; + let ffmpeg: FFmpeg | null = null; + let ffmpegLoaded = false; + setLoading(true); + try { + ffmpeg = new FFmpeg(); + if (!ffmpegLoaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + ffmpegLoaded = true; + } + const fileName = input.name; + const outputName = `output.${outputFormat}`; + await ffmpeg.writeFile(fileName, await fetchFile(input)); + const audioFilter = computeAudioFilter(newSpeed); + let args = ['-i', fileName, '-filter:a', audioFilter]; + if (outputFormat === 'mp3') { + args.push('-b:a', '192k', '-f', 'mp3', outputName); + } else if (outputFormat === 'aac') { + args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName); + } else if (outputFormat === 'wav') { + args.push( + '-acodec', + 'pcm_s16le', + '-ar', + '44100', + '-ac', + '2', + '-f', + 'wav', + outputName + ); + } + await ffmpeg.exec(args); + const data = await ffmpeg.readFile(outputName); + let mimeType = 'audio/mp3'; + if (outputFormat === 'aac') mimeType = 'audio/aac'; + if (outputFormat === 'wav') mimeType = 'audio/wav'; + const blob = new Blob([data], { type: mimeType }); + const newFile = new File( + [blob], + fileName.replace(/\.[^/.]+$/, `-${newSpeed}x.${outputFormat}`), + { type: mimeType } + ); + await ffmpeg.deleteFile(fileName); + await ffmpeg.deleteFile(outputName); + setResult(newFile); + } catch (err) { + console.error(`Failed to process audio: ${err}`); + setResult(null); + } finally { + setLoading(false); + } + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'New Audio Speed', + component: ( + + updateField('newSpeed', Number(val))} + description="Default multiplier: 2 means 2x faster" + type="number" + /> + + ) + }, + { + title: 'Output Format', + component: ( + + + updateField( + 'outputFormat', + e.target.value as 'mp3' | 'aac' | 'wav' + ) + } + > + {formatOptions.map((opt) => ( + } + label={opt.label} + /> + ))} + + + ) + } + ]; + return ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/audio/change-speed/meta.ts b/src/pages/tools/audio/change-speed/meta.ts new file mode 100644 index 0000000..a23d674 --- /dev/null +++ b/src/pages/tools/audio/change-speed/meta.ts @@ -0,0 +1,13 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('audio', { + name: 'Change speed', + path: 'change-speed', + icon: 'material-symbols-light:speed-outline', + description: + 'This online utility lets you change the speed of an audio. You can speed it up or slow it down.', + shortDescription: 'Quickly change audio speed', + keywords: ['change', 'speed'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/audio/change-speed/service.ts b/src/pages/tools/audio/change-speed/service.ts new file mode 100644 index 0000000..18d6dd6 --- /dev/null +++ b/src/pages/tools/audio/change-speed/service.ts @@ -0,0 +1,8 @@ +import { InitialValuesType } from './types'; + +export function main( + input: File | null, + options: InitialValuesType +): File | null { + return input; +} diff --git a/src/pages/tools/audio/change-speed/types.ts b/src/pages/tools/audio/change-speed/types.ts new file mode 100644 index 0000000..587fc4f --- /dev/null +++ b/src/pages/tools/audio/change-speed/types.ts @@ -0,0 +1,4 @@ +export type InitialValuesType = { + newSpeed: number; + outputFormat: 'mp3' | 'aac' | 'wav'; +}; diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts index 40b2828..1c2a77d 100644 --- a/src/pages/tools/audio/index.ts +++ b/src/pages/tools/audio/index.ts @@ -1,2 +1,4 @@ +import { tool as audioChangeSpeed } from './change-speed/meta'; import { tool as audioExtractAudio } from './extract-audio/meta'; -export const audioTools = [audioExtractAudio]; + +export const audioTools = [audioExtractAudio, audioChangeSpeed]; From 7962bba04fe83af46cbc81b000b8382241ea64c1 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 15:50:06 -0700 Subject: [PATCH 03/41] feat: introduce audio merging and trimming tools with support for multiple formats --- .../input/ToolMultipleAudioInput.tsx | 174 ++++++++++++++++++ .../change-speed/change-speed.service.test.ts | 35 ++++ src/pages/tools/audio/index.ts | 9 +- src/pages/tools/audio/merge-audio/index.tsx | 112 +++++++++++ .../merge-audio/merge-audio.service.test.ts | 73 ++++++++ src/pages/tools/audio/merge-audio/meta.ts | 26 +++ src/pages/tools/audio/merge-audio/service.ts | 94 ++++++++++ src/pages/tools/audio/merge-audio/types.ts | 3 + src/pages/tools/audio/trim/index.tsx | 128 +++++++++++++ src/pages/tools/audio/trim/meta.ts | 27 +++ src/pages/tools/audio/trim/service.ts | 108 +++++++++++ .../tools/audio/trim/trim.service.test.ts | 58 ++++++ src/pages/tools/audio/trim/types.ts | 5 + 13 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 src/components/input/ToolMultipleAudioInput.tsx create mode 100644 src/pages/tools/audio/change-speed/change-speed.service.test.ts create mode 100644 src/pages/tools/audio/merge-audio/index.tsx create mode 100644 src/pages/tools/audio/merge-audio/merge-audio.service.test.ts create mode 100644 src/pages/tools/audio/merge-audio/meta.ts create mode 100644 src/pages/tools/audio/merge-audio/service.ts create mode 100644 src/pages/tools/audio/merge-audio/types.ts create mode 100644 src/pages/tools/audio/trim/index.tsx create mode 100644 src/pages/tools/audio/trim/meta.ts create mode 100644 src/pages/tools/audio/trim/service.ts create mode 100644 src/pages/tools/audio/trim/trim.service.test.ts create mode 100644 src/pages/tools/audio/trim/types.ts diff --git a/src/components/input/ToolMultipleAudioInput.tsx b/src/components/input/ToolMultipleAudioInput.tsx new file mode 100644 index 0000000..938c35a --- /dev/null +++ b/src/components/input/ToolMultipleAudioInput.tsx @@ -0,0 +1,174 @@ +import { ReactNode, useContext, useEffect, useRef, useState } from 'react'; +import { Box, useTheme } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import InputHeader from '../InputHeader'; +import InputFooter from './InputFooter'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import { isArray } from 'lodash'; +import MusicNoteIcon from '@mui/icons-material/MusicNote'; + +interface MultiAudioInputComponentProps { + accept: string[]; + title?: string; + type: 'audio'; + value: MultiAudioInput[]; + onChange: (file: MultiAudioInput[]) => void; +} + +export interface MultiAudioInput { + file: File; + order: number; +} + +export default function ToolMultipleAudioInput({ + value, + onChange, + accept, + title, + type +}: MultiAudioInputComponentProps) { + const theme = useTheme(); + const fileInputRef = useRef(null); + const { showSnackBar } = useContext(CustomSnackBarContext); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (files) + onChange([ + ...value, + ...Array.from(files).map((file) => ({ file, order: value.length })) + ]); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + function handleClear() { + onChange([]); + } + + function fileNameTruncate(fileName: string) { + const maxLength = 15; + if (fileName.length > maxLength) { + return fileName.slice(0, maxLength) + '...'; + } + return fileName; + } + + const sortList = () => { + const list = [...value]; + list.sort((a, b) => a.order - b.order); + onChange(list); + }; + + const reorderList = (sourceIndex: number, destinationIndex: number) => { + if (destinationIndex === sourceIndex) { + return; + } + const list = [...value]; + + if (destinationIndex === 0) { + list[sourceIndex].order = list[0].order - 1; + sortList(); + return; + } + + if (destinationIndex === list.length - 1) { + list[sourceIndex].order = list[list.length - 1].order + 1; + sortList(); + return; + } + + if (destinationIndex < sourceIndex) { + list[sourceIndex].order = + (list[destinationIndex].order + list[destinationIndex - 1].order) / 2; + sortList(); + return; + } + + list[sourceIndex].order = + (list[destinationIndex].order + list[destinationIndex + 1].order) / 2; + sortList(); + }; + + return ( + + + + + {value?.length ? ( + value.map((file, index) => ( + + + + + {fileNameTruncate(file.file.name)} + + + { + const updatedFiles = value.filter((_, i) => i !== index); + onChange(updatedFiles); + }} + > + ✖ + + + )) + ) : ( + + No files selected + + )} + + + + + + + ); +} diff --git a/src/pages/tools/audio/change-speed/change-speed.service.test.ts b/src/pages/tools/audio/change-speed/change-speed.service.test.ts new file mode 100644 index 0000000..cf2c91f --- /dev/null +++ b/src/pages/tools/audio/change-speed/change-speed.service.test.ts @@ -0,0 +1,35 @@ +import { expect, describe, it, vi } from 'vitest'; + +// Mock FFmpeg since it doesn't support Node.js +vi.mock('@ffmpeg/ffmpeg', () => ({ + FFmpeg: vi.fn().mockImplementation(() => ({ + loaded: false, + load: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])), + deleteFile: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) +})); + +import { main } from './service'; +import { InitialValuesType } from './types'; + +describe('changeSpeed (main)', () => { + it('should return the input file unchanged (mock implementation)', () => { + const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockFile = new File([mockAudioData], 'test.mp3', { + type: 'audio/mp3' + }); + const options: InitialValuesType = { + newSpeed: 2, + outputFormat: 'mp3' + }; + const result = main(mockFile, options); + expect(result).toBe(mockFile); + }); +}); diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts index 1c2a77d..3596a1c 100644 --- a/src/pages/tools/audio/index.ts +++ b/src/pages/tools/audio/index.ts @@ -1,4 +1,11 @@ +import { tool as audioMergeAudio } from './merge-audio/meta'; +import { tool as audioTrim } from './trim/meta'; import { tool as audioChangeSpeed } from './change-speed/meta'; import { tool as audioExtractAudio } from './extract-audio/meta'; -export const audioTools = [audioExtractAudio, audioChangeSpeed]; +export const audioTools = [ + audioExtractAudio, + audioChangeSpeed, + audioTrim, + audioMergeAudio +]; diff --git a/src/pages/tools/audio/merge-audio/index.tsx b/src/pages/tools/audio/merge-audio/index.tsx new file mode 100644 index 0000000..1a98cd8 --- /dev/null +++ b/src/pages/tools/audio/merge-audio/index.tsx @@ -0,0 +1,112 @@ +import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { InitialValuesType } from './types'; +import ToolMultipleAudioInput, { + MultiAudioInput +} from '@components/input/ToolMultipleAudioInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import { mergeAudioFiles } from './service'; + +const initialValues: InitialValuesType = { + outputFormat: 'mp3' +}; + +const formatOptions = [ + { label: 'MP3', value: 'mp3' }, + { label: 'AAC', value: 'aac' }, + { label: 'WAV', value: 'wav' } +]; + +export default function MergeAudio({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState([]); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const compute = async ( + optionsValues: InitialValuesType, + input: MultiAudioInput[] + ) => { + if (input.length === 0) return; + setLoading(true); + try { + const files = input.map((item) => item.file); + const mergedFile = await mergeAudioFiles(files, optionsValues); + setResult(mergedFile); + } catch (err) { + console.error(`Failed to merge audio: ${err}`); + setResult(null); + } finally { + setLoading(false); + } + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'Output Format', + component: ( + + + updateField( + 'outputFormat', + e.target.value as 'mp3' | 'aac' | 'wav' + ) + } + > + {formatOptions.map((opt) => ( + } + label={opt.label} + /> + ))} + + + ) + } + ]; + + return ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/audio/merge-audio/merge-audio.service.test.ts b/src/pages/tools/audio/merge-audio/merge-audio.service.test.ts new file mode 100644 index 0000000..6bdaaab --- /dev/null +++ b/src/pages/tools/audio/merge-audio/merge-audio.service.test.ts @@ -0,0 +1,73 @@ +import { expect, describe, it, vi } from 'vitest'; + +// Mock FFmpeg since it doesn't support Node.js +vi.mock('@ffmpeg/ffmpeg', () => ({ + FFmpeg: vi.fn().mockImplementation(() => ({ + loaded: false, + load: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])), + deleteFile: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) +})); + +import { mergeAudioFiles } from './service'; + +describe('mergeAudioFiles', () => { + it('should merge multiple audio files', async () => { + // Create mock audio files + const mockAudioData1 = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockAudioData2 = new Uint8Array([6, 7, 8, 9, 10, 11]); + + const mockFile1 = new File([mockAudioData1], 'test1.mp3', { + type: 'audio/mp3' + }); + const mockFile2 = new File([mockAudioData2], 'test2.mp3', { + type: 'audio/mp3' + }); + + const options = { + outputFormat: 'mp3' as const + }; + + const result = await mergeAudioFiles([mockFile1, mockFile2], options); + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('merged_audio.mp3'); + expect(result.type).toBe('audio/mp3'); + }); + + it('should handle different output formats', async () => { + const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockFile = new File([mockAudioData], 'test.wav', { + type: 'audio/wav' + }); + + const options = { + outputFormat: 'aac' as const + }; + + const result = await mergeAudioFiles([mockFile], options); + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('merged_audio.aac'); + expect(result.type).toBe('audio/aac'); + }); + + it('should throw error when no input files provided', async () => { + const options = { + outputFormat: 'mp3' as const + }; + + try { + await mergeAudioFiles([], options); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('No input files provided'); + } + }); +}); diff --git a/src/pages/tools/audio/merge-audio/meta.ts b/src/pages/tools/audio/merge-audio/meta.ts new file mode 100644 index 0000000..3420eb9 --- /dev/null +++ b/src/pages/tools/audio/merge-audio/meta.ts @@ -0,0 +1,26 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('audio', { + name: 'Merge Audio', + path: 'merge-audio', + icon: 'mdi:music-note-multiple', + description: + 'Combine multiple audio files into a single audio file by concatenating them in sequence.', + shortDescription: 'Merge multiple audio files into one (MP3, AAC, WAV).', + keywords: [ + 'merge', + 'audio', + 'combine', + 'concatenate', + 'join', + 'mp3', + 'aac', + 'wav', + 'audio editing', + 'multiple files' + ], + longDescription: + 'This tool allows you to merge multiple audio files into a single file by concatenating them in the order you upload them. Perfect for combining podcast segments, music tracks, or any audio files that need to be joined together. Supports various audio formats including MP3, AAC, and WAV.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/audio/merge-audio/service.ts b/src/pages/tools/audio/merge-audio/service.ts new file mode 100644 index 0000000..d0ba10d --- /dev/null +++ b/src/pages/tools/audio/merge-audio/service.ts @@ -0,0 +1,94 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import { InitialValuesType } from './types'; + +const ffmpeg = new FFmpeg(); + +export async function mergeAudioFiles( + inputs: File[], + options: InitialValuesType +): Promise { + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + if (inputs.length === 0) { + throw new Error('No input files provided'); + } + + const { outputFormat } = options; + const outputName = `output.${outputFormat}`; + + // Write all input files to FFmpeg + const inputNames: string[] = []; + for (let i = 0; i < inputs.length; i++) { + const inputName = `input${i}.mp3`; + await ffmpeg.writeFile(inputName, await fetchFile(inputs[i])); + inputNames.push(inputName); + } + + // Create a file list for concatenation + const fileListName = 'filelist.txt'; + const fileListContent = inputNames.map((name) => `file '${name}'`).join('\n'); + await ffmpeg.writeFile(fileListName, fileListContent); + + // Build FFmpeg arguments for merging + let args: string[] = ['-f', 'concat', '-safe', '0', '-i', fileListName]; + + // Add format-specific arguments + if (outputFormat === 'mp3') { + args.push( + '-ar', + '44100', + '-ac', + '2', + '-b:a', + '192k', + '-f', + 'mp3', + outputName + ); + } else if (outputFormat === 'aac') { + args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName); + } else if (outputFormat === 'wav') { + args.push( + '-acodec', + 'pcm_s16le', + '-ar', + '44100', + '-ac', + '2', + '-f', + 'wav', + outputName + ); + } + + await ffmpeg.exec(args); + + const mergedAudio = await ffmpeg.readFile(outputName); + + let mimeType = 'audio/mp3'; + if (outputFormat === 'aac') mimeType = 'audio/aac'; + if (outputFormat === 'wav') mimeType = 'audio/wav'; + + // Clean up files + for (const inputName of inputNames) { + await ffmpeg.deleteFile(inputName); + } + await ffmpeg.deleteFile(fileListName); + await ffmpeg.deleteFile(outputName); + + return new File( + [ + new Blob([mergedAudio], { + type: mimeType + }) + ], + `merged_audio.${outputFormat}`, + { type: mimeType } + ); +} diff --git a/src/pages/tools/audio/merge-audio/types.ts b/src/pages/tools/audio/merge-audio/types.ts new file mode 100644 index 0000000..2a7c90c --- /dev/null +++ b/src/pages/tools/audio/merge-audio/types.ts @@ -0,0 +1,3 @@ +export type InitialValuesType = { + outputFormat: 'mp3' | 'aac' | 'wav'; +}; diff --git a/src/pages/tools/audio/trim/index.tsx b/src/pages/tools/audio/trim/index.tsx new file mode 100644 index 0000000..c3bd84b --- /dev/null +++ b/src/pages/tools/audio/trim/index.tsx @@ -0,0 +1,128 @@ +import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { InitialValuesType } from './types'; +import ToolAudioInput from '@components/input/ToolAudioInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import { trimAudio } from './service'; + +const initialValues: InitialValuesType = { + startTime: '00:00:00', + endTime: '00:01:00', + outputFormat: 'mp3' +}; + +const formatOptions = [ + { label: 'MP3', value: 'mp3' }, + { label: 'AAC', value: 'aac' }, + { label: 'WAV', value: 'wav' } +]; + +export default function Trim({ title, longDescription }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const compute = async ( + optionsValues: InitialValuesType, + input: File | null + ) => { + if (!input) return; + setLoading(true); + try { + const trimmedFile = await trimAudio(input, optionsValues); + setResult(trimmedFile); + } catch (err) { + console.error(`Failed to trim audio: ${err}`); + setResult(null); + } finally { + setLoading(false); + } + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'Time Settings', + component: ( + + updateField('startTime', val)} + description="Start time in format HH:MM:SS (e.g., 00:00:30)" + label="Start Time" + /> + + updateField('endTime', val)} + description="End time in format HH:MM:SS (e.g., 00:01:30)" + label="End Time" + /> + + + ) + }, + { + title: 'Output Format', + component: ( + + + updateField( + 'outputFormat', + e.target.value as 'mp3' | 'aac' | 'wav' + ) + } + > + {formatOptions.map((opt) => ( + } + label={opt.label} + /> + ))} + + + ) + } + ]; + + return ( + + } + resultComponent={ + loading ? ( + + ) : ( + + ) + } + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/audio/trim/meta.ts b/src/pages/tools/audio/trim/meta.ts new file mode 100644 index 0000000..a4f36e3 --- /dev/null +++ b/src/pages/tools/audio/trim/meta.ts @@ -0,0 +1,27 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('audio', { + name: 'Trim Audio', + path: 'trim', + icon: 'mdi:scissors-cutting', + description: + 'Cut and trim audio files to extract specific segments by specifying start and end times.', + shortDescription: + 'Trim audio files to extract specific time segments (MP3, AAC, WAV).', + keywords: [ + 'trim', + 'audio', + 'cut', + 'segment', + 'extract', + 'mp3', + 'aac', + 'wav', + 'audio editing', + 'time' + ], + longDescription: + 'This tool allows you to trim audio files by specifying start and end times. You can extract specific segments from longer audio files, remove unwanted parts, or create shorter clips. Supports various audio formats including MP3, AAC, and WAV. Perfect for podcast editing, music production, or any audio editing needs.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/audio/trim/service.ts b/src/pages/tools/audio/trim/service.ts new file mode 100644 index 0000000..e17f78a --- /dev/null +++ b/src/pages/tools/audio/trim/service.ts @@ -0,0 +1,108 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; +import { InitialValuesType } from './types'; + +const ffmpeg = new FFmpeg(); + +export async function trimAudio( + input: File, + options: InitialValuesType +): Promise { + if (!ffmpeg.loaded) { + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); + } + + const inputName = 'input.mp3'; + await ffmpeg.writeFile(inputName, await fetchFile(input)); + + const { startTime, endTime, outputFormat } = options; + const outputName = `output.${outputFormat}`; + + // Build FFmpeg arguments for trimming + let args: string[] = [ + '-i', + inputName, + '-ss', + startTime, // Start time + '-to', + endTime, // End time + '-c', + 'copy' // Copy without re-encoding for speed + ]; + + // Add format-specific arguments + if (outputFormat === 'mp3') { + args = [ + '-i', + inputName, + '-ss', + startTime, + '-to', + endTime, + '-ar', + '44100', + '-ac', + '2', + '-b:a', + '192k', + '-f', + 'mp3', + outputName + ]; + } else if (outputFormat === 'aac') { + args = [ + '-i', + inputName, + '-ss', + startTime, + '-to', + endTime, + '-c:a', + 'aac', + '-b:a', + '192k', + '-f', + 'adts', + outputName + ]; + } else if (outputFormat === 'wav') { + args = [ + '-i', + inputName, + '-ss', + startTime, + '-to', + endTime, + '-acodec', + 'pcm_s16le', + '-ar', + '44100', + '-ac', + '2', + '-f', + 'wav', + outputName + ]; + } + + await ffmpeg.exec(args); + + const trimmedAudio = await ffmpeg.readFile(outputName); + + let mimeType = 'audio/mp3'; + if (outputFormat === 'aac') mimeType = 'audio/aac'; + if (outputFormat === 'wav') mimeType = 'audio/wav'; + + return new File( + [ + new Blob([trimmedAudio], { + type: mimeType + }) + ], + `${input.name.replace(/\.[^/.]+$/, '')}_trimmed.${outputFormat}`, + { type: mimeType } + ); +} diff --git a/src/pages/tools/audio/trim/trim.service.test.ts b/src/pages/tools/audio/trim/trim.service.test.ts new file mode 100644 index 0000000..5d9faa5 --- /dev/null +++ b/src/pages/tools/audio/trim/trim.service.test.ts @@ -0,0 +1,58 @@ +import { expect, describe, it, vi } from 'vitest'; + +// Mock FFmpeg since it doesn't support Node.js +vi.mock('@ffmpeg/ffmpeg', () => ({ + FFmpeg: vi.fn().mockImplementation(() => ({ + loaded: false, + load: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])), + deleteFile: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) +})); + +import { trimAudio } from './service'; + +describe('trimAudio', () => { + it('should trim audio file with valid time parameters', async () => { + // Create a mock audio file + const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockFile = new File([mockAudioData], 'test.mp3', { + type: 'audio/mp3' + }); + + const options = { + startTime: '00:00:10', + endTime: '00:00:20', + outputFormat: 'mp3' as const + }; + + const result = await trimAudio(mockFile, options); + expect(result).toBeInstanceOf(File); + expect(result.name).toContain('_trimmed.mp3'); + expect(result.type).toBe('audio/mp3'); + }); + + it('should handle different output formats', async () => { + const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockFile = new File([mockAudioData], 'test.wav', { + type: 'audio/wav' + }); + + const options = { + startTime: '00:00:00', + endTime: '00:00:30', + outputFormat: 'wav' as const + }; + + const result = await trimAudio(mockFile, options); + expect(result).toBeInstanceOf(File); + expect(result.name).toContain('_trimmed.wav'); + expect(result.type).toBe('audio/wav'); + }); +}); diff --git a/src/pages/tools/audio/trim/types.ts b/src/pages/tools/audio/trim/types.ts new file mode 100644 index 0000000..425f07e --- /dev/null +++ b/src/pages/tools/audio/trim/types.ts @@ -0,0 +1,5 @@ +export type InitialValuesType = { + startTime: string; + endTime: string; + outputFormat: 'mp3' | 'aac' | 'wav'; +}; From fe41c092f631c108d2078e53900499738c638f9a Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 21:27:32 -0700 Subject: [PATCH 04/41] feat: add Crontab Guru tool for parsing and validating crontab expressions --- package-lock.json | 2 + package.json | 2 + src/components/input/ToolTextInput.tsx | 5 +- .../crontab-guru/crontab-guru.service.test.ts | 26 +++++ src/pages/tools/time/crontab-guru/index.tsx | 108 ++++++++++++++++++ src/pages/tools/time/crontab-guru/meta.ts | 24 ++++ src/pages/tools/time/crontab-guru/service.ts | 23 ++++ src/pages/tools/time/crontab-guru/types.ts | 4 + src/pages/tools/time/index.ts | 4 +- 9 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/pages/tools/time/crontab-guru/crontab-guru.service.test.ts create mode 100644 src/pages/tools/time/crontab-guru/index.tsx create mode 100644 src/pages/tools/time/crontab-guru/meta.ts create mode 100644 src/pages/tools/time/crontab-guru/service.ts create mode 100644 src/pages/tools/time/crontab-guru/types.ts diff --git a/package-lock.json b/package-lock.json index 173cc4e..4600001 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,8 @@ "browser-image-compression": "^2.0.2", "buffer": "^6.0.3", "color": "^4.2.3", + "cron-validator": "^1.3.1", + "cronstrue": "^3.0.0", "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", diff --git a/package.json b/package.json index ed7ebcf..86276e5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "browser-image-compression": "^2.0.2", "buffer": "^6.0.3", "color": "^4.2.3", + "cron-validator": "^1.3.1", + "cronstrue": "^3.0.0", "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", diff --git a/src/components/input/ToolTextInput.tsx b/src/components/input/ToolTextInput.tsx index e5741f6..793e0d7 100644 --- a/src/components/input/ToolTextInput.tsx +++ b/src/components/input/ToolTextInput.tsx @@ -7,11 +7,13 @@ import InputFooter from './InputFooter'; export default function ToolTextInput({ value, onChange, - title = 'Input text' + title = 'Input text', + placeholder }: { title?: string; value: string; onChange: (value: string) => void; + placeholder?: string; }) { const { showSnackBar } = useContext(CustomSnackBarContext); const fileInputRef = useRef(null); @@ -50,6 +52,7 @@ export default function ToolTextInput({ fullWidth multiline rows={10} + placeholder={placeholder} sx={{ '&.MuiTextField-root': { backgroundColor: 'background.paper' diff --git a/src/pages/tools/time/crontab-guru/crontab-guru.service.test.ts b/src/pages/tools/time/crontab-guru/crontab-guru.service.test.ts new file mode 100644 index 0000000..bce7fd9 --- /dev/null +++ b/src/pages/tools/time/crontab-guru/crontab-guru.service.test.ts @@ -0,0 +1,26 @@ +import { expect, describe, it } from 'vitest'; +import { validateCrontab, explainCrontab } from './service'; + +describe('crontab-guru service', () => { + it('validates correct crontab expressions', () => { + expect(validateCrontab('35 16 * * 0-5')).toBe(true); + expect(validateCrontab('* * * * *')).toBe(true); + expect(validateCrontab('0 12 1 * *')).toBe(true); + }); + + it('invalidates incorrect crontab expressions', () => { + expect(validateCrontab('invalid expression')).toBe(false); + expect(validateCrontab('61 24 * * *')).toBe(false); + }); + + it('explains valid crontab expressions', () => { + expect(explainCrontab('35 16 * * 0-5')).toMatch(/At 04:35 PM/); + expect(explainCrontab('* * * * *')).toMatch(/Every minute/); + }); + + it('returns error for invalid crontab explanation', () => { + expect(explainCrontab('invalid expression')).toMatch( + /Invalid crontab expression/ + ); + }); +}); diff --git a/src/pages/tools/time/crontab-guru/index.tsx b/src/pages/tools/time/crontab-guru/index.tsx new file mode 100644 index 0000000..4d39ed2 --- /dev/null +++ b/src/pages/tools/time/crontab-guru/index.tsx @@ -0,0 +1,108 @@ +import { Box, Typography, Alert, Button, Stack } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import { main, validateCrontab, explainCrontab } from './service'; +import { InitialValuesType } from './types'; + +const initialValues: InitialValuesType = {}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Every day at 16:35, Sunday to Friday', + description: 'At 16:35 on every day-of-week from Sunday through Friday.', + sampleText: '35 16 * * 0-5', + sampleResult: 'At 04:35 PM, Sunday through Friday', + sampleOptions: {} + }, + { + title: 'Every minute', + description: 'Runs every minute.', + sampleText: '* * * * *', + sampleResult: 'Every minute', + sampleOptions: {} + }, + { + title: 'Every 5 minutes', + description: 'Runs every 5 minutes.', + sampleText: '*/5 * * * *', + sampleResult: 'Every 5 minutes', + sampleOptions: {} + }, + { + title: 'At 12:00 PM on the 1st of every month', + description: 'Runs at noon on the first day of each month.', + sampleText: '0 12 1 * *', + sampleResult: 'At 12:00 PM, on day 1 of the month', + sampleOptions: {} + } +]; + +export default function CrontabGuru({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + const [isValid, setIsValid] = useState(null); + + const compute = (values: InitialValuesType, input: string) => { + setIsValid(validateCrontab(input)); + setResult(main(input, values)); + }; + + const handleExample = (expr: string) => { + setInput(expr); + setIsValid(validateCrontab(expr)); + setResult(main(expr, initialValues)); + }; + + const getGroups: GetGroupsType | null = () => []; + + return ( + + + + {exampleCards.map((ex, i) => ( + + ))} + + + } + resultComponent={ + <> + {isValid === false && ( + Invalid crontab expression. + )} + + + } + initialValues={initialValues} + exampleCards={exampleCards} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/time/crontab-guru/meta.ts b/src/pages/tools/time/crontab-guru/meta.ts new file mode 100644 index 0000000..7ae5994 --- /dev/null +++ b/src/pages/tools/time/crontab-guru/meta.ts @@ -0,0 +1,24 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('time', { + name: 'Crontab Guru', + path: 'crontab-guru', + icon: 'mdi:calendar-clock', + description: + 'Parse, validate, and explain crontab expressions in plain English.', + shortDescription: 'Crontab expression parser and explainer', + keywords: [ + 'crontab', + 'cron', + 'schedule', + 'guru', + 'time', + 'expression', + 'parser', + 'explain' + ], + longDescription: + 'Enter a crontab expression (like "35 16 * * 0-5") to get a human-readable explanation and validation. Useful for understanding and debugging cron schedules. Inspired by crontab.guru.', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/time/crontab-guru/service.ts b/src/pages/tools/time/crontab-guru/service.ts new file mode 100644 index 0000000..1c81ba8 --- /dev/null +++ b/src/pages/tools/time/crontab-guru/service.ts @@ -0,0 +1,23 @@ +import { InitialValuesType } from './types'; +import cronstrue from 'cronstrue'; +import { isValidCron } from 'cron-validator'; + +export function explainCrontab(expr: string): string { + try { + return cronstrue.toString(expr); + } catch (e: any) { + return `Invalid crontab expression: ${e.message}`; + } +} + +export function validateCrontab(expr: string): boolean { + return isValidCron(expr, { seconds: false, allowBlankDay: true }); +} + +export function main(input: string, options: InitialValuesType): string { + if (!input.trim()) return ''; + if (!validateCrontab(input)) { + return 'Invalid crontab expression.'; + } + return explainCrontab(input); +} diff --git a/src/pages/tools/time/crontab-guru/types.ts b/src/pages/tools/time/crontab-guru/types.ts new file mode 100644 index 0000000..54b3d7e --- /dev/null +++ b/src/pages/tools/time/crontab-guru/types.ts @@ -0,0 +1,4 @@ +// Options for crontab-guru tool. Currently empty, but can be extended for advanced features. +export type InitialValuesType = { + // Add future options here +}; diff --git a/src/pages/tools/time/index.ts b/src/pages/tools/time/index.ts index 9b80e65..1e9145d 100644 --- a/src/pages/tools/time/index.ts +++ b/src/pages/tools/time/index.ts @@ -1,3 +1,4 @@ +import { tool as timeCrontabGuru } from './crontab-guru/meta'; import { tool as timeBetweenDates } from './time-between-dates/meta'; import { tool as daysDoHours } from './convert-days-to-hours/meta'; import { tool as hoursToDays } from './convert-hours-to-days/meta'; @@ -11,5 +12,6 @@ export const timeTools = [ convertSecondsToTime, convertTimetoSeconds, truncateClockTime, - timeBetweenDates + timeBetweenDates, + timeCrontabGuru ]; From a613bdb4c54e86541d5ccdec85758a91a6982aa2 Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 21:49:02 -0700 Subject: [PATCH 05/41] feat: enhance Crontab Guru tool with interaction tracking and improved validation feedback --- package-lock.json | 15 ++++++ src/pages/tools/time/crontab-guru/index.tsx | 58 ++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4600001..5cefa39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4821,6 +4821,21 @@ "node": ">= 6" } }, + "node_modules/cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==", + "license": "MIT" + }, + "node_modules/cronstrue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-3.0.0.tgz", + "integrity": "sha512-acwNTPzndJUmfDmcUN2cpBH4EgVn30rg5BYDAP8n5ENPP8A3IH2Z0UbxaNjvCkKxccjtfsTVhF6d+eHhv/GK5g==", + "license": "MIT", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/src/pages/tools/time/crontab-guru/index.tsx b/src/pages/tools/time/crontab-guru/index.tsx index 4d39ed2..20033c3 100644 --- a/src/pages/tools/time/crontab-guru/index.tsx +++ b/src/pages/tools/time/crontab-guru/index.tsx @@ -49,18 +49,27 @@ export default function CrontabGuru({ const [input, setInput] = useState(''); const [result, setResult] = useState(''); const [isValid, setIsValid] = useState(null); + const [hasInteracted, setHasInteracted] = useState(false); const compute = (values: InitialValuesType, input: string) => { - setIsValid(validateCrontab(input)); + if (hasInteracted) { + setIsValid(validateCrontab(input)); + } setResult(main(input, values)); }; const handleExample = (expr: string) => { setInput(expr); + setHasInteracted(true); setIsValid(validateCrontab(expr)); setResult(main(expr, initialValues)); }; + const handleInputChange = (val: string) => { + if (!hasInteracted) setHasInteracted(true); + setInput(val); + }; + const getGroups: GetGroupsType | null = () => []; return ( @@ -71,7 +80,7 @@ export default function CrontabGuru({ <> @@ -90,12 +99,47 @@ export default function CrontabGuru({ } resultComponent={ - <> - {isValid === false && ( - Invalid crontab expression. +
+ {hasInteracted && isValid === false && ( +
+ + Invalid crontab expression. + +
)} - - +
+ +
+
} initialValues={initialValues} exampleCards={exampleCards} From fb6dd816a1ae7df69482107a867903ac842dfd4a Mon Sep 17 00:00:00 2001 From: AshAnand34 Date: Mon, 7 Jul 2025 22:07:46 -0700 Subject: [PATCH 06/41] refactor: simplify initial values handling and remove unused types in Crontab Guru tool --- src/pages/tools/time/crontab-guru/index.tsx | 5 +++-- src/pages/tools/time/crontab-guru/service.ts | 3 +-- src/pages/tools/time/crontab-guru/types.ts | 4 ---- 3 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 src/pages/tools/time/crontab-guru/types.ts diff --git a/src/pages/tools/time/crontab-guru/index.tsx b/src/pages/tools/time/crontab-guru/index.tsx index 20033c3..d5785db 100644 --- a/src/pages/tools/time/crontab-guru/index.tsx +++ b/src/pages/tools/time/crontab-guru/index.tsx @@ -7,9 +7,10 @@ import ToolTextResult from '@components/result/ToolTextResult'; import { GetGroupsType } from '@components/options/ToolOptions'; import { CardExampleType } from '@components/examples/ToolExamples'; import { main, validateCrontab, explainCrontab } from './service'; -import { InitialValuesType } from './types'; -const initialValues: InitialValuesType = {}; +const initialValues = {}; + +type InitialValuesType = typeof initialValues; const exampleCards: CardExampleType[] = [ { diff --git a/src/pages/tools/time/crontab-guru/service.ts b/src/pages/tools/time/crontab-guru/service.ts index 1c81ba8..638636c 100644 --- a/src/pages/tools/time/crontab-guru/service.ts +++ b/src/pages/tools/time/crontab-guru/service.ts @@ -1,4 +1,3 @@ -import { InitialValuesType } from './types'; import cronstrue from 'cronstrue'; import { isValidCron } from 'cron-validator'; @@ -14,7 +13,7 @@ export function validateCrontab(expr: string): boolean { return isValidCron(expr, { seconds: false, allowBlankDay: true }); } -export function main(input: string, options: InitialValuesType): string { +export function main(input: string, _options: any): string { if (!input.trim()) return ''; if (!validateCrontab(input)) { return 'Invalid crontab expression.'; diff --git a/src/pages/tools/time/crontab-guru/types.ts b/src/pages/tools/time/crontab-guru/types.ts deleted file mode 100644 index 54b3d7e..0000000 --- a/src/pages/tools/time/crontab-guru/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Options for crontab-guru tool. Currently empty, but can be extended for advanced features. -export type InitialValuesType = { - // Add future options here -}; From f3ed70258e60e81c44967fedb7bfb9332a2e6141 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Tue, 8 Jul 2025 17:43:44 +0100 Subject: [PATCH 07/41] fix(audio): misc --- .idea/workspace.xml | 57 ++++++++++++------- .../input/ToolMultipleAudioInput.tsx | 2 - src/pages/tools/audio/change-speed/index.tsx | 2 +- src/pages/tools/audio/extract-audio/index.tsx | 22 ++----- .../tools/audio/extract-audio/service.ts | 2 +- src/pages/tools/audio/merge-audio/index.tsx | 2 +- src/pages/tools/audio/merge-audio/meta.ts | 2 +- src/pages/tools/audio/merge-audio/service.ts | 2 +- src/pages/tools/audio/trim/index.tsx | 2 +- src/tools/index.ts | 14 ++--- 10 files changed, 55 insertions(+), 52 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 5d8fccb..b9472f7 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,11 +4,15 @@