mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-22 07:29:39 +02:00
Merge remote-tracking branch 'origin/main'
# Conflicts: # .idea/workspace.xml
This commit is contained in:
48
package-lock.json
generated
48
package-lock.json
generated
@@ -26,7 +26,10 @@
|
||||
"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",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"formik": "^2.4.6",
|
||||
"jimp": "^0.22.12",
|
||||
"js-quantities": "^1.8.0",
|
||||
@@ -4819,6 +4822,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",
|
||||
@@ -5876,6 +5894,24 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
|
||||
@@ -10485,6 +10521,18 @@
|
||||
"integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
|
||||
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
|
@@ -43,7 +43,10 @@
|
||||
"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",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"formik": "^2.4.6",
|
||||
"jimp": "^0.22.12",
|
||||
"js-quantities": "^1.8.0",
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import { BrowserRouter, useRoutes } from 'react-router-dom';
|
||||
import routesConfig from '../config/routesConfig';
|
||||
import Navbar from './Navbar';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import { Suspense, useState, useEffect } from 'react';
|
||||
import Loading from './Loading';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { CssBaseline, Theme, ThemeProvider } from '@mui/material';
|
||||
import { CustomSnackBarProvider } from '../contexts/CustomSnackBarContext';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { tools } from '../tools';
|
||||
@@ -11,6 +11,8 @@ import './index.css';
|
||||
import { darkTheme, lightTheme } from '../config/muiConfig';
|
||||
import ScrollToTopButton from './ScrollToTopButton';
|
||||
|
||||
export type Mode = 'dark' | 'light' | 'system';
|
||||
|
||||
const AppRoutes = () => {
|
||||
const updatedRoutesConfig = [...routesConfig];
|
||||
tools.forEach((tool) => {
|
||||
@@ -20,10 +22,26 @@ const AppRoutes = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
const theme = useMemo(() => (darkMode ? darkTheme : lightTheme), [darkMode]);
|
||||
const [mode, setMode] = useState<Mode>(
|
||||
() => (localStorage.getItem('theme') || 'system') as Mode
|
||||
);
|
||||
const [theme, setTheme] = useState<Theme>(() => getTheme(mode));
|
||||
useEffect(() => setTheme(getTheme(mode)), [mode]);
|
||||
|
||||
// Make sure to update the theme when the mode changes
|
||||
useEffect(() => {
|
||||
const systemDarkModeQuery = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
);
|
||||
const handleThemeChange = (e: MediaQueryListEvent) => {
|
||||
setTheme(e.matches ? darkTheme : lightTheme);
|
||||
};
|
||||
systemDarkModeQuery.addEventListener('change', handleThemeChange);
|
||||
|
||||
return () => {
|
||||
systemDarkModeQuery.removeEventListener('change', handleThemeChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
@@ -38,9 +56,10 @@ function App() {
|
||||
<CustomSnackBarProvider>
|
||||
<BrowserRouter>
|
||||
<Navbar
|
||||
onSwitchTheme={() => {
|
||||
setDarkMode((prevState) => !prevState);
|
||||
localStorage.setItem('theme', darkMode ? 'light' : 'dark');
|
||||
mode={mode}
|
||||
onChangeMode={() => {
|
||||
setMode((prev) => nextMode(prev));
|
||||
localStorage.setItem('theme', nextMode(mode));
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
@@ -54,4 +73,21 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function getTheme(mode: Mode): Theme {
|
||||
switch (mode) {
|
||||
case 'dark':
|
||||
return darkTheme;
|
||||
case 'light':
|
||||
return lightTheme;
|
||||
default:
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? darkTheme
|
||||
: lightTheme;
|
||||
}
|
||||
}
|
||||
|
||||
function nextMode(mode: Mode): Mode {
|
||||
return mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light';
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@@ -1,4 +1,13 @@
|
||||
import { Autocomplete, Box, Stack, TextField, useTheme } from '@mui/material';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
darken,
|
||||
lighten,
|
||||
Stack,
|
||||
styled,
|
||||
TextField,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Grid from '@mui/material/Grid';
|
||||
@@ -8,7 +17,22 @@ import { filterTools, tools } from '@tools/index';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import _ from 'lodash';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { getToolCategoryTitle } from '@utils/string';
|
||||
|
||||
const GroupHeader = styled('div')(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
top: '-8px',
|
||||
padding: '4px 10px',
|
||||
color: theme.palette.primary.main,
|
||||
backgroundColor: lighten(theme.palette.primary.light, 0.85),
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: darken(theme.palette.primary.main, 0.8)
|
||||
})
|
||||
}));
|
||||
|
||||
const GroupItems = styled('ul')({
|
||||
padding: 0
|
||||
});
|
||||
const exampleTools: { label: string; url: string }[] = [
|
||||
{
|
||||
label: 'Create a transparent image',
|
||||
@@ -26,9 +50,7 @@ const exampleTools: { label: string; url: string }[] = [
|
||||
export default function Hero() {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const theme = useTheme();
|
||||
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(
|
||||
_.shuffle(tools)
|
||||
);
|
||||
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(tools);
|
||||
const navigate = useNavigate();
|
||||
const handleInputChange = (
|
||||
event: React.ChangeEvent<{}>,
|
||||
@@ -66,6 +88,15 @@ export default function Hero() {
|
||||
sx={{ mb: 2 }}
|
||||
autoHighlight
|
||||
options={filteredTools}
|
||||
groupBy={(option) => option.type}
|
||||
renderGroup={(params) => {
|
||||
return (
|
||||
<li key={params.key}>
|
||||
<GroupHeader>{getToolCategoryTitle(params.group)}</GroupHeader>
|
||||
<GroupItems>{params.children}</GroupItems>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
getOptionLabel={(option) => option.name}
|
||||
renderInput={(params) => (
|
||||
|
@@ -17,13 +17,17 @@ import {
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Icon } from '@iconify/react';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import { Mode } from 'components/App';
|
||||
|
||||
interface NavbarProps {
|
||||
onSwitchTheme: () => void;
|
||||
mode: Mode;
|
||||
onChangeMode: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
mode,
|
||||
onChangeMode: onChangeMode
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -37,7 +41,19 @@ const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
|
||||
];
|
||||
|
||||
const buttons: ReactNode[] = [
|
||||
<DarkModeIcon onClick={onSwitchTheme} style={{ cursor: 'pointer' }} />,
|
||||
<Icon
|
||||
key={mode}
|
||||
onClick={onChangeMode}
|
||||
style={{ cursor: 'pointer' }}
|
||||
fontSize={30}
|
||||
icon={
|
||||
mode === 'dark'
|
||||
? 'ic:round-dark-mode'
|
||||
: mode === 'light'
|
||||
? 'ic:round-light-mode'
|
||||
: 'ic:round-contrast'
|
||||
}
|
||||
/>,
|
||||
<Icon
|
||||
onClick={() => window.open('https://discord.gg/SDbbn3hT4b', '_blank')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
|
46
src/components/input/ToolAudioInput.tsx
Normal file
46
src/components/input/ToolAudioInput.tsx
Normal file
@@ -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<BaseFileInputProps, 'accept'> {
|
||||
accept?: string[];
|
||||
}
|
||||
|
||||
export default function ToolAudioInput({
|
||||
accept = ['audio/*', '.mp3', '.wav', '.aac'],
|
||||
...props
|
||||
}: AudioFileInputProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
return (
|
||||
<BaseFileInput {...props} type={'audio'} accept={accept}>
|
||||
{({ preview }) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={preview}
|
||||
style={{ maxWidth: '100%' }}
|
||||
controls
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Drag & drop or import an audio file
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</BaseFileInput>
|
||||
);
|
||||
}
|
172
src/components/input/ToolMultipleAudioInput.tsx
Normal file
172
src/components/input/ToolMultipleAudioInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Box>
|
||||
<InputHeader
|
||||
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '300px',
|
||||
border: value?.length ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{value?.length ? (
|
||||
value.map((file, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
margin: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '200px',
|
||||
border: 1,
|
||||
borderRadius: 1,
|
||||
padding: 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MusicNoteIcon />
|
||||
<Typography sx={{ marginLeft: 1 }}>
|
||||
{fileNameTruncate(file.file.name)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
const updatedFiles = value.filter((_, i) => i !== index);
|
||||
onChange(updatedFiles);
|
||||
}}
|
||||
>
|
||||
✖
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No files selected
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
accept={accept.join(',')}
|
||||
onChange={handleFileChange}
|
||||
multiple={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -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<HTMLInputElement>(null);
|
||||
@@ -50,6 +52,7 @@ export default function ToolTextInput({
|
||||
fullWidth
|
||||
multiline
|
||||
rows={10}
|
||||
placeholder={placeholder}
|
||||
sx={{
|
||||
'&.MuiTextField-root': {
|
||||
backgroundColor: 'background.paper'
|
||||
|
@@ -4,7 +4,7 @@ import Typography from '@mui/material/Typography';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { filterTools, getToolsByCategory } from '../../tools';
|
||||
import Hero from 'components/Hero';
|
||||
import { capitalizeFirstLetter } from '@utils/string';
|
||||
import { capitalizeFirstLetter, getToolCategoryTitle } from '@utils/string';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { categoriesColors } from 'config/uiConfig';
|
||||
import React, { useEffect } from 'react';
|
||||
@@ -21,9 +21,7 @@ export default function ToolsByCategory() {
|
||||
const mainContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const { categoryName } = useParams();
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>('');
|
||||
const rawTitle = getToolsByCategory().find(
|
||||
(category) => category.type === categoryName
|
||||
)!.rawTitle;
|
||||
const rawTitle = getToolCategoryTitle(categoryName as string);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainContentRef.current) {
|
||||
|
@@ -0,0 +1,46 @@
|
||||
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 { changeAudioSpeed } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
describe('changeAudioSpeed', () => {
|
||||
it('should return a new File with the correct name and type', async () => {
|
||||
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 = await changeAudioSpeed(mockFile, options);
|
||||
expect(result).toBeInstanceOf(File);
|
||||
expect(result?.name).toBe('test-2x.mp3');
|
||||
expect(result?.type).toBe('audio/mp3');
|
||||
});
|
||||
|
||||
it('should return null if input is null', async () => {
|
||||
const options: InitialValuesType = {
|
||||
newSpeed: 2,
|
||||
outputFormat: 'mp3'
|
||||
};
|
||||
const result = await changeAudioSpeed(null, options);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
120
src/pages/tools/audio/change-speed/index.tsx
Normal file
120
src/pages/tools/audio/change-speed/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 RadioWithTextField from '@components/options/RadioWithTextField';
|
||||
import { changeAudioSpeed } from './service';
|
||||
|
||||
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<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: InitialValuesType,
|
||||
input: File | null
|
||||
) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const newFile = await changeAudioSpeed(input, optionsValues);
|
||||
setResult(newFile);
|
||||
} catch (err) {
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'New Audio Speed',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.newSpeed.toString()}
|
||||
onOwnChange={(val) => updateField('newSpeed', Number(val))}
|
||||
description="Default multiplier: 2 means 2x faster"
|
||||
type="number"
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Output Format',
|
||||
component: (
|
||||
<Box mt={2}>
|
||||
<RadioGroup
|
||||
row
|
||||
value={values.outputFormat}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'outputFormat',
|
||||
e.target.value as 'mp3' | 'aac' | 'wav'
|
||||
)
|
||||
}
|
||||
>
|
||||
{formatOptions.map((opt) => (
|
||||
<FormControlLabel
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
control={<Radio />}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolAudioInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={'Input Audio'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult title="Setting Speed" value={null} loading={true} />
|
||||
) : (
|
||||
<ToolFileResult
|
||||
title="Edited Audio"
|
||||
value={result}
|
||||
extension={result ? result.name.split('.').pop() : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/audio/change-speed/meta.ts
Normal file
13
src/pages/tools/audio/change-speed/meta.ts
Normal file
@@ -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'))
|
||||
});
|
80
src/pages/tools/audio/change-speed/service.ts
Normal file
80
src/pages/tools/audio/change-speed/service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
function 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(',');
|
||||
}
|
||||
|
||||
export async function changeAudioSpeed(
|
||||
input: File | null,
|
||||
options: InitialValuesType
|
||||
): Promise<File | null> {
|
||||
if (!input) return null;
|
||||
const { newSpeed, outputFormat } = options;
|
||||
let ffmpeg: FFmpeg | null = null;
|
||||
let ffmpegLoaded = false;
|
||||
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);
|
||||
return newFile;
|
||||
} catch (err) {
|
||||
console.error(`Failed to process audio: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
4
src/pages/tools/audio/change-speed/types.ts
Normal file
4
src/pages/tools/audio/change-speed/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type InitialValuesType = {
|
||||
newSpeed: number;
|
||||
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||
};
|
@@ -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');
|
||||
});
|
||||
});
|
91
src/pages/tools/audio/extract-audio/index.tsx
Normal file
91
src/pages/tools/audio/extract-audio/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useState } 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<File | null>(null);
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: 'Output Format',
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.outputFormat}
|
||||
onChange={(value) => {
|
||||
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.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const audioFileObj = await extractAudioFromVideo(input, values);
|
||||
setAudioFile(audioFileObj);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={file}
|
||||
inputComponent={
|
||||
<ToolVideoInput value={file} onChange={setFile} title={'Input Video'} />
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult
|
||||
title={'Extracting Audio'}
|
||||
value={null}
|
||||
loading={true}
|
||||
/>
|
||||
) : (
|
||||
<ToolFileResult title={'Extracted Audio'} value={audioFile} />
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
|
||||
setInput={setFile}
|
||||
/>
|
||||
);
|
||||
}
|
26
src/pages/tools/audio/extract-audio/meta.ts
Normal file
26
src/pages/tools/audio/extract-audio/meta.ts
Normal file
@@ -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'))
|
||||
});
|
70
src/pages/tools/audio/extract-audio/service.ts
Normal file
70
src/pages/tools/audio/extract-audio/service.ts
Normal file
@@ -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<File> {
|
||||
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}`;
|
||||
const 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}` }
|
||||
);
|
||||
}
|
3
src/pages/tools/audio/extract-audio/types.ts
Normal file
3
src/pages/tools/audio/extract-audio/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
outputFormat: string;
|
||||
};
|
11
src/pages/tools/audio/index.ts
Normal file
11
src/pages/tools/audio/index.ts
Normal file
@@ -0,0 +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,
|
||||
audioTrim,
|
||||
audioMergeAudio
|
||||
];
|
112
src/pages/tools/audio/merge-audio/index.tsx
Normal file
112
src/pages/tools/audio/merge-audio/index.tsx
Normal file
@@ -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<MultiAudioInput[]>([]);
|
||||
const [result, setResult] = useState<File | null>(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<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Output Format',
|
||||
component: (
|
||||
<Box mt={2}>
|
||||
<RadioGroup
|
||||
row
|
||||
value={values.outputFormat}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'outputFormat',
|
||||
e.target.value as 'mp3' | 'aac' | 'wav'
|
||||
)
|
||||
}
|
||||
>
|
||||
{formatOptions.map((opt) => (
|
||||
<FormControlLabel
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
control={<Radio />}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolMultipleAudioInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['audio/*', '.mp3', '.wav', '.aac']}
|
||||
title={'Input Audio Files'}
|
||||
type="audio"
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult title="Merging Audio" value={null} loading={true} />
|
||||
) : (
|
||||
<ToolFileResult
|
||||
title="Merged Audio"
|
||||
value={result}
|
||||
extension={result ? result.name.split('.').pop() : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
26
src/pages/tools/audio/merge-audio/meta.ts
Normal file
26
src/pages/tools/audio/merge-audio/meta.ts
Normal file
@@ -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: 'fluent:merge-20-regular',
|
||||
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'))
|
||||
});
|
115
src/pages/tools/audio/merge-audio/service.ts
Normal file
115
src/pages/tools/audio/merge-audio/service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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<File> {
|
||||
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}`;
|
||||
|
||||
// 1. Convert all inputs to WAV
|
||||
const tempWavNames: string[] = [];
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const inputName = `input${i}`;
|
||||
const tempWavName = `temp${i}.wav`;
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(inputs[i]));
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
inputName,
|
||||
'-acodec',
|
||||
'pcm_s16le',
|
||||
'-ar',
|
||||
'44100',
|
||||
'-ac',
|
||||
'2',
|
||||
tempWavName
|
||||
]);
|
||||
tempWavNames.push(tempWavName);
|
||||
await ffmpeg.deleteFile(inputName);
|
||||
}
|
||||
|
||||
// 2. Create file list for concat
|
||||
const fileListName = 'filelist.txt';
|
||||
const fileListContent = tempWavNames
|
||||
.map((name) => `file '${name}'`)
|
||||
.join('\n');
|
||||
await ffmpeg.writeFile(fileListName, fileListContent);
|
||||
|
||||
// 3. Concatenate WAV files
|
||||
const concatWav = 'concat.wav';
|
||||
await ffmpeg.exec([
|
||||
'-f',
|
||||
'concat',
|
||||
'-safe',
|
||||
'0',
|
||||
'-i',
|
||||
fileListName,
|
||||
'-c',
|
||||
'copy',
|
||||
concatWav
|
||||
]);
|
||||
|
||||
// 4. Convert concatenated WAV to requested output format
|
||||
let finalOutput = concatWav;
|
||||
if (outputFormat !== 'wav') {
|
||||
const args = ['-i', concatWav];
|
||||
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);
|
||||
}
|
||||
await ffmpeg.exec(args);
|
||||
finalOutput = outputName;
|
||||
}
|
||||
|
||||
const mergedAudio = await ffmpeg.readFile(finalOutput);
|
||||
|
||||
let mimeType = 'audio/wav';
|
||||
if (outputFormat === 'mp3') mimeType = 'audio/mp3';
|
||||
if (outputFormat === 'aac') mimeType = 'audio/aac';
|
||||
|
||||
// Clean up files
|
||||
for (const tempWavName of tempWavNames) {
|
||||
await ffmpeg.deleteFile(tempWavName);
|
||||
}
|
||||
await ffmpeg.deleteFile(fileListName);
|
||||
await ffmpeg.deleteFile(concatWav);
|
||||
if (outputFormat !== 'wav') {
|
||||
await ffmpeg.deleteFile(outputName);
|
||||
}
|
||||
|
||||
return new File(
|
||||
[
|
||||
new Blob([mergedAudio], {
|
||||
type: mimeType
|
||||
})
|
||||
],
|
||||
`merged_audio.${outputFormat}`,
|
||||
{ type: mimeType }
|
||||
);
|
||||
}
|
3
src/pages/tools/audio/merge-audio/types.ts
Normal file
3
src/pages/tools/audio/merge-audio/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||
};
|
128
src/pages/tools/audio/trim/index.tsx
Normal file
128
src/pages/tools/audio/trim/index.tsx
Normal file
@@ -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<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(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<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Time Settings',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.startTime}
|
||||
onOwnChange={(val) => updateField('startTime', val)}
|
||||
description="Start time in format HH:MM:SS (e.g., 00:00:30)"
|
||||
label="Start Time"
|
||||
/>
|
||||
<Box mt={2}>
|
||||
<TextFieldWithDesc
|
||||
value={values.endTime}
|
||||
onOwnChange={(val) => updateField('endTime', val)}
|
||||
description="End time in format HH:MM:SS (e.g., 00:01:30)"
|
||||
label="End Time"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Output Format',
|
||||
component: (
|
||||
<Box mt={2}>
|
||||
<RadioGroup
|
||||
row
|
||||
value={values.outputFormat}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'outputFormat',
|
||||
e.target.value as 'mp3' | 'aac' | 'wav'
|
||||
)
|
||||
}
|
||||
>
|
||||
{formatOptions.map((opt) => (
|
||||
<FormControlLabel
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
control={<Radio />}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolAudioInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
title={'Input Audio'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult title="Trimming Audio" value={null} loading={true} />
|
||||
) : (
|
||||
<ToolFileResult
|
||||
title="Trimmed Audio"
|
||||
value={result}
|
||||
extension={result ? result.name.split('.').pop() : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
27
src/pages/tools/audio/trim/meta.ts
Normal file
27
src/pages/tools/audio/trim/meta.ts
Normal file
@@ -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'))
|
||||
});
|
108
src/pages/tools/audio/trim/service.ts
Normal file
108
src/pages/tools/audio/trim/service.ts
Normal file
@@ -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<File> {
|
||||
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 }
|
||||
);
|
||||
}
|
58
src/pages/tools/audio/trim/trim.service.test.ts
Normal file
58
src/pages/tools/audio/trim/trim.service.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
5
src/pages/tools/audio/trim/types.ts
Normal file
5
src/pages/tools/audio/trim/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type InitialValuesType = {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
outputFormat: 'mp3' | 'aac' | 'wav';
|
||||
};
|
158
src/pages/tools/string/censor/index.tsx
Normal file
158
src/pages/tools/string/censor/index.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import ToolTextResult from '@components/result/ToolTextResult';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { censorText } from './service';
|
||||
import ToolTextInput from '@components/input/ToolTextInput';
|
||||
import { InitialValuesType } from './types';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
wordsToCensor: '',
|
||||
censoredBySymbol: true,
|
||||
censorSymbol: '█',
|
||||
eachLetter: true,
|
||||
censorWord: 'CENSORED'
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Censor a Word in a Quote',
|
||||
description: `In this example, we hide the unpleasant word "idiot" from Jim Rohn's quote. We specify this word in the words-to-censor option and mask it with a neat smiling face character "☺".`,
|
||||
sampleText:
|
||||
'Motivation alone is not enough. If you have an idiot and you motivate him, now you have a motivated idiot. Jim Rohn',
|
||||
sampleResult:
|
||||
'Motivation alone is not enough. If you have an ☺ and you motivate him, now you have a motivated ☺. Jim Rohn',
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
wordsToCensor: 'idiot',
|
||||
censorSymbol: '☺',
|
||||
eachLetter: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Censor an Excerpt',
|
||||
description: `In this example, we censor multiple words from an excerpt from the novel "The Guns of Avalon" by Roger Zelazny. To do this, we write out all unnecessary words in the multi-line text option and select the "Use a Symbol to Censor" censoring mode. We activate the "Mask Each Letter" option so that in place of each word exactly as many block characters "█" appeared as there are letters in that word.`,
|
||||
sampleText:
|
||||
'“In the mirrors of the many judgments, my hands are the color of blood. I sometimes fancy myself an evil which exists to oppose other evils; and on that great Day of which the prophets speak but in which they do not truly believe, on the day the world is utterly cleansed of evil, then I too will go down into darkness, swallowing curses. Until then, I will not wash my hands nor let them hang useless.” ― Roger Zelazny, The Guns of Avalon',
|
||||
sampleResult:
|
||||
'“In the mirrors of the many judgments, my hands are the color of █████. I sometimes fancy myself an ████ which exists to oppose other █████; and on that great Day of which the prophets speak but in which they do not truly believe, on the day the world is utterly cleansed of ████, then I too will go down into ████████, swallowing ██████. Until then, I will not wash my hands nor let them hang useless.” ― Roger Zelazny, The Guns of Avalon',
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
wordsToCensor: 'blood\nevil\ndarkness\ncurses',
|
||||
eachLetter: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Censor Agent's Name",
|
||||
description: `In this example, we hide the name of an undercover FBI agent. We replace two words at once (first name and last name) with the code name "Agent 007"`,
|
||||
sampleText:
|
||||
'My name is John and I am an undercover FBI agent. I usually write my name in lowercase as "john" because I find uppercase letters scary. Unfortunately, in documents, my name is properly capitalized as John and it makes me upset.',
|
||||
sampleResult:
|
||||
'My name is Agent 007 and I am an undercover FBI agent. I usually write my name in lowercase as "Agent 007" because I find uppercase letters scary. Unfortunately, in documents, my name is properly capitalized as Agent 007 and it makes me upset.',
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
censoredBySymbol: false,
|
||||
wordsToCensor: 'john',
|
||||
censorWord: 'Agent 007'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function CensorText({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
function compute(initialValues: InitialValuesType, input: string) {
|
||||
setResult(censorText(input, initialValues));
|
||||
}
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Words to Censor',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
multiline
|
||||
rows={3}
|
||||
value={values.wordsToCensor}
|
||||
onOwnChange={(val) => updateField('wordsToCensor', val)}
|
||||
description={`Specify all unwanted words that
|
||||
you want to hide in text (separated by a new line)`}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Censor Mode',
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.censoredBySymbol}
|
||||
options={[
|
||||
{ label: 'Censor by Symbol', value: true },
|
||||
{ label: 'Censor by Word', value: false }
|
||||
]}
|
||||
onChange={(value) => updateField('censoredBySymbol', value)}
|
||||
description={'Select the censoring mode.'}
|
||||
/>
|
||||
|
||||
{values.censoredBySymbol && (
|
||||
<TextFieldWithDesc
|
||||
value={values.censorSymbol}
|
||||
onOwnChange={(val) => updateField('censorSymbol', val)}
|
||||
description={`A symbol, character, or pattern to use for censoring.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.censoredBySymbol && (
|
||||
<CheckboxWithDesc
|
||||
checked={values.eachLetter}
|
||||
onChange={(value) => updateField('eachLetter', value)}
|
||||
title="Mask each letter"
|
||||
description="Put a masking symbol in place of each letter of the censored word."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!values.censoredBySymbol && (
|
||||
<TextFieldWithDesc
|
||||
value={values.censorWord}
|
||||
onOwnChange={(val) => updateField('censorWord', val)}
|
||||
description={`Replace all censored words with this word.`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={compute}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
inputComponent={
|
||||
<ToolTextInput title={'Input text'} value={input} onChange={setInput} />
|
||||
}
|
||||
resultComponent={
|
||||
<ToolTextResult title={'Censored text'} value={result} />
|
||||
}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
exampleCards={exampleCards}
|
||||
/>
|
||||
);
|
||||
}
|
16
src/pages/tools/string/censor/meta.ts
Normal file
16
src/pages/tools/string/censor/meta.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('string', {
|
||||
name: 'Text Censor',
|
||||
path: 'censor',
|
||||
shortDescription:
|
||||
'Quickly mask bad words or replace them with alternative words.',
|
||||
icon: 'hugeicons:text-footnote',
|
||||
description:
|
||||
"utility for censoring words in text. Load your text in the input form on the left, specify all the bad words in the options, and you'll instantly get censored text in the output area.",
|
||||
longDescription:
|
||||
'With this online tool, you can censor certain words in any text. You can specify a list of unwanted words (such as swear words or secret words) and the program will replace them with alternative words and create a safe-to-read text. The words can be specified in a multi-line text field in the options by entering one word per line.',
|
||||
keywords: ['text', 'censor', 'words', 'characters'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
49
src/pages/tools/string/censor/service.ts
Normal file
49
src/pages/tools/string/censor/service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
export function censorText(input: string, options: InitialValuesType): string {
|
||||
if (!input) return '';
|
||||
if (!options.wordsToCensor) return input;
|
||||
|
||||
if (options.censoredBySymbol && !isSymbol(options.censorSymbol)) {
|
||||
throw new Error('Enter a valid censor symbol (non-alphanumeric or emoji)');
|
||||
}
|
||||
|
||||
const wordsToCensor = options.wordsToCensor
|
||||
.split('\n')
|
||||
.map((word) => word.trim())
|
||||
.filter((word) => word.length > 0);
|
||||
|
||||
let censoredText = input;
|
||||
|
||||
for (const word of wordsToCensor) {
|
||||
const escapedWord = escapeRegex(word);
|
||||
const pattern = new RegExp(`\\b${escapedWord}\\b`, 'giu');
|
||||
|
||||
const replacement = options.censoredBySymbol
|
||||
? options.eachLetter
|
||||
? options.censorSymbol.repeat(word.length)
|
||||
: options.censorSymbol
|
||||
: options.censorWord;
|
||||
|
||||
censoredText = censoredText.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
return censoredText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes RegExp special characters in a string
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a string is a valid symbol or emoji (multi-codepoint supported).
|
||||
*/
|
||||
function isSymbol(input: string): boolean {
|
||||
return (
|
||||
/^[^\p{L}\p{N}]+$/u.test(input) || // Not a letter or number
|
||||
/\p{Extended_Pictographic}/u.test(input) // Emoji or pictographic symbol
|
||||
);
|
||||
}
|
7
src/pages/tools/string/censor/types.ts
Normal file
7
src/pages/tools/string/censor/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type InitialValuesType = {
|
||||
wordsToCensor: string;
|
||||
censoredBySymbol: boolean;
|
||||
censorSymbol: string;
|
||||
eachLetter: boolean;
|
||||
censorWord: string;
|
||||
};
|
@@ -16,6 +16,7 @@ import { tool as stringRepeat } from './repeat/meta';
|
||||
import { tool as stringTruncate } from './truncate/meta';
|
||||
import { tool as stringBase64 } from './base64/meta';
|
||||
import { tool as stringStatistic } from './statistic/meta';
|
||||
import { tool as stringCensor } from './censor/meta';
|
||||
|
||||
export const stringTools = [
|
||||
stringSplit,
|
||||
@@ -35,5 +36,6 @@ export const stringTools = [
|
||||
stringRotate,
|
||||
stringRot13,
|
||||
stringBase64,
|
||||
stringStatistic
|
||||
stringStatistic,
|
||||
stringCensor
|
||||
];
|
||||
|
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
128
src/pages/tools/time/crontab-guru/index.tsx
Normal file
128
src/pages/tools/time/crontab-guru/index.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Alert } 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 { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { main, validateCrontab } from './service';
|
||||
|
||||
const initialValues = {};
|
||||
|
||||
type InitialValuesType = typeof initialValues;
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
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<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
const [isValid, setIsValid] = useState<boolean | null>(null);
|
||||
const [hasInteracted, setHasInteracted] = useState<boolean>(false);
|
||||
|
||||
const compute = (values: InitialValuesType, input: string) => {
|
||||
if (hasInteracted) {
|
||||
setIsValid(validateCrontab(input));
|
||||
}
|
||||
setResult(main(input, values));
|
||||
};
|
||||
|
||||
const handleInputChange = (val: string) => {
|
||||
if (!hasInteracted) setHasInteracted(true);
|
||||
setInput(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="e.g. 35 16 * * 0-5"
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<div style={{ position: 'relative', minHeight: 80 }}>
|
||||
{hasInteracted && isValid === false && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 2,
|
||||
pointerEvents: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'transparent'
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="error"
|
||||
style={{
|
||||
width: '80%',
|
||||
opacity: 0.85,
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
Invalid crontab expression.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
filter: hasInteracted && isValid === false ? 'blur(1px)' : 'none',
|
||||
transition: 'filter 0.2s'
|
||||
}}
|
||||
>
|
||||
<ToolTextResult
|
||||
value={hasInteracted && isValid === false ? '' : result}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={null}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
24
src/pages/tools/time/crontab-guru/meta.ts
Normal file
24
src/pages/tools/time/crontab-guru/meta.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('time', {
|
||||
name: 'Crontab explainer',
|
||||
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'))
|
||||
});
|
22
src/pages/tools/time/crontab-guru/service.ts
Normal file
22
src/pages/tools/time/crontab-guru/service.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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: any): string {
|
||||
if (!input.trim()) return '';
|
||||
if (!validateCrontab(input)) {
|
||||
return 'Invalid crontab expression.';
|
||||
}
|
||||
return explainCrontab(input);
|
||||
}
|
@@ -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
|
||||
];
|
||||
|
4
src/pages/tools/xml/index.ts
Normal file
4
src/pages/tools/xml/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { tool as xmlXmlValidator } from './xml-validator/meta';
|
||||
import { tool as xmlXmlBeautifier } from './xml-beautifier/meta';
|
||||
import { tool as xmlXmlViewer } from './xml-viewer/meta';
|
||||
export const xmlTools = [xmlXmlViewer, xmlXmlBeautifier, xmlXmlValidator];
|
54
src/pages/tools/xml/xml-beautifier/index.tsx
Normal file
54
src/pages/tools/xml/xml-beautifier/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Box } 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 { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { beautifyXml } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Beautify XML',
|
||||
description: 'Beautify a compact XML string for readability.',
|
||||
sampleText: '<root><item>1</item><item>2</item></root>',
|
||||
sampleResult: `<root>\n <item>1</item>\n <item>2</item>\n</root>`,
|
||||
sampleOptions: {}
|
||||
}
|
||||
];
|
||||
|
||||
export default function XmlBeautifier({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (_values: InitialValuesType, input: string) => {
|
||||
setResult(beautifyXml(input, {}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Paste or import XML here..."
|
||||
/>
|
||||
}
|
||||
resultComponent={<ToolTextResult value={result} extension="xml" />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={null}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/xml/xml-beautifier/meta.ts
Normal file
13
src/pages/tools/xml/xml-beautifier/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('xml', {
|
||||
name: 'XML Beautifier',
|
||||
path: 'xml-beautifier',
|
||||
icon: 'mdi:format-align-left',
|
||||
description:
|
||||
'Beautify and reformat XML for improved readability and structure.',
|
||||
shortDescription: 'Beautify XML for readability.',
|
||||
keywords: ['xml', 'beautify', 'format', 'pretty', 'indent'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
23
src/pages/tools/xml/xml-beautifier/service.ts
Normal file
23
src/pages/tools/xml/xml-beautifier/service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { XMLParser, XMLBuilder, XMLValidator } from 'fast-xml-parser';
|
||||
|
||||
export function beautifyXml(
|
||||
input: string,
|
||||
_options: InitialValuesType
|
||||
): string {
|
||||
const valid = XMLValidator.validate(input);
|
||||
if (valid !== true) {
|
||||
if (typeof valid === 'object' && valid.err) {
|
||||
return `Invalid XML: ${valid.err.msg} (line ${valid.err.line}, col ${valid.err.col})`;
|
||||
}
|
||||
return 'Invalid XML';
|
||||
}
|
||||
try {
|
||||
const parser = new XMLParser();
|
||||
const obj = parser.parse(input);
|
||||
const builder = new XMLBuilder({ format: true, indentBy: ' ' });
|
||||
return builder.build(obj);
|
||||
} catch (e: any) {
|
||||
return `Invalid XML: ${e.message}`;
|
||||
}
|
||||
}
|
3
src/pages/tools/xml/xml-beautifier/types.ts
Normal file
3
src/pages/tools/xml/xml-beautifier/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
// splitSeparator: string;
|
||||
};
|
@@ -0,0 +1,18 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { beautifyXml } from './service';
|
||||
|
||||
describe('xml-beautifier', () => {
|
||||
it('beautifies valid XML', () => {
|
||||
const input = '<root><a>1</a><b>2</b></root>';
|
||||
const result = beautifyXml(input, {});
|
||||
expect(result).toContain('<root>');
|
||||
expect(result).toContain(' <a>1</a>');
|
||||
expect(result).toContain(' <b>2</b>');
|
||||
});
|
||||
|
||||
it('returns error for invalid XML', () => {
|
||||
const input = '<root><a>1</b></root>';
|
||||
const result = beautifyXml(input, {});
|
||||
expect(result).toMatch(/Invalid XML/i);
|
||||
});
|
||||
});
|
61
src/pages/tools/xml/xml-validator/index.tsx
Normal file
61
src/pages/tools/xml/xml-validator/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box } 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 { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { validateXml } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Validate XML',
|
||||
description: 'Check if an XML string is well-formed.',
|
||||
sampleText: '<root><item>1</item><item>2</item></root>',
|
||||
sampleResult: 'Valid XML',
|
||||
sampleOptions: {}
|
||||
},
|
||||
{
|
||||
title: 'Invalid XML',
|
||||
description: 'Example of malformed XML.',
|
||||
sampleText: '<root><item>1</item><item>2</root>',
|
||||
sampleResult: 'Invalid XML: ...',
|
||||
sampleOptions: {}
|
||||
}
|
||||
];
|
||||
|
||||
export default function XmlValidator({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (_values: InitialValuesType, input: string) => {
|
||||
setResult(validateXml(input, {}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Paste or import XML here..."
|
||||
/>
|
||||
}
|
||||
resultComponent={<ToolTextResult value={result} extension="txt" />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={null}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/xml/xml-validator/meta.ts
Normal file
13
src/pages/tools/xml/xml-validator/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('xml', {
|
||||
name: 'XML Validator',
|
||||
path: 'xml-validator',
|
||||
icon: 'mdi:check-decagram',
|
||||
description:
|
||||
'Validate XML files or strings to ensure they are well-formed and error-free.',
|
||||
shortDescription: 'Validate XML for errors.',
|
||||
keywords: ['xml', 'validate', 'check', 'syntax', 'error'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
16
src/pages/tools/xml/xml-validator/service.ts
Normal file
16
src/pages/tools/xml/xml-validator/service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { XMLValidator } from 'fast-xml-parser';
|
||||
|
||||
export function validateXml(
|
||||
input: string,
|
||||
_options: InitialValuesType
|
||||
): string {
|
||||
const result = XMLValidator.validate(input);
|
||||
if (result === true) {
|
||||
return 'Valid XML';
|
||||
} else if (typeof result === 'object' && result.err) {
|
||||
return `Invalid XML: ${result.err.msg} (line ${result.err.line}, col ${result.err.col})`;
|
||||
} else {
|
||||
return 'Invalid XML: Unknown error';
|
||||
}
|
||||
}
|
3
src/pages/tools/xml/xml-validator/types.ts
Normal file
3
src/pages/tools/xml/xml-validator/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
// splitSeparator: string;
|
||||
};
|
@@ -0,0 +1,16 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { validateXml } from './service';
|
||||
|
||||
describe('xml-validator', () => {
|
||||
it('returns Valid XML for well-formed XML', () => {
|
||||
const input = '<root><a>1</a><b>2</b></root>';
|
||||
const result = validateXml(input, {});
|
||||
expect(result).toBe('Valid XML');
|
||||
});
|
||||
|
||||
it('returns error for invalid XML', () => {
|
||||
const input = '<root><a>1</b></root>';
|
||||
const result = validateXml(input, {});
|
||||
expect(result).toMatch(/Invalid XML/i);
|
||||
});
|
||||
});
|
54
src/pages/tools/xml/xml-viewer/index.tsx
Normal file
54
src/pages/tools/xml/xml-viewer/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Box } 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 { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { prettyPrintXml } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Pretty Print XML',
|
||||
description: 'View and pretty-print a compact XML string.',
|
||||
sampleText: '<root><item>1</item><item>2</item></root>',
|
||||
sampleResult: `<root>\n <item>1</item>\n <item>2</item>\n</root>`,
|
||||
sampleOptions: {}
|
||||
}
|
||||
];
|
||||
|
||||
export default function XmlViewer({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (_values: InitialValuesType, input: string) => {
|
||||
setResult(prettyPrintXml(input, {}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Paste or import XML here..."
|
||||
/>
|
||||
}
|
||||
resultComponent={<ToolTextResult value={result} extension="xml" />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={null}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/xml/xml-viewer/meta.ts
Normal file
13
src/pages/tools/xml/xml-viewer/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('xml', {
|
||||
name: 'XML Viewer',
|
||||
path: 'xml-viewer',
|
||||
icon: 'mdi:eye-outline',
|
||||
description:
|
||||
'View and pretty-print XML files or strings for easier reading and debugging.',
|
||||
shortDescription: 'Pretty-print and view XML.',
|
||||
keywords: ['xml', 'viewer', 'pretty print', 'format', 'inspect'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
23
src/pages/tools/xml/xml-viewer/service.ts
Normal file
23
src/pages/tools/xml/xml-viewer/service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { XMLParser, XMLBuilder, XMLValidator } from 'fast-xml-parser';
|
||||
|
||||
export function prettyPrintXml(
|
||||
input: string,
|
||||
_options: InitialValuesType
|
||||
): string {
|
||||
const valid = XMLValidator.validate(input);
|
||||
if (valid !== true) {
|
||||
if (typeof valid === 'object' && valid.err) {
|
||||
return `Invalid XML: ${valid.err.msg} (line ${valid.err.line}, col ${valid.err.col})`;
|
||||
}
|
||||
return 'Invalid XML';
|
||||
}
|
||||
try {
|
||||
const parser = new XMLParser();
|
||||
const obj = parser.parse(input);
|
||||
const builder = new XMLBuilder({ format: true, indentBy: ' ' });
|
||||
return builder.build(obj);
|
||||
} catch (e: any) {
|
||||
return `Invalid XML: ${e.message}`;
|
||||
}
|
||||
}
|
3
src/pages/tools/xml/xml-viewer/types.ts
Normal file
3
src/pages/tools/xml/xml-viewer/types.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type InitialValuesType = {
|
||||
// splitSeparator: string;
|
||||
};
|
18
src/pages/tools/xml/xml-viewer/xml-viewer.service.test.ts
Normal file
18
src/pages/tools/xml/xml-viewer/xml-viewer.service.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { prettyPrintXml } from './service';
|
||||
|
||||
describe('xml-viewer', () => {
|
||||
it('pretty prints valid XML', () => {
|
||||
const input = '<root><a>1</a><b>2</b></root>';
|
||||
const result = prettyPrintXml(input, {});
|
||||
expect(result).toContain('<root>');
|
||||
expect(result).toContain(' <a>1</a>');
|
||||
expect(result).toContain(' <b>2</b>');
|
||||
});
|
||||
|
||||
it('returns error for invalid XML', () => {
|
||||
const input = '<root><a>1</b></root>';
|
||||
const result = prettyPrintXml(input, {});
|
||||
expect(result).toMatch(/Invalid XML/i);
|
||||
});
|
||||
});
|
@@ -24,7 +24,9 @@ export type ToolCategory =
|
||||
| 'time'
|
||||
| 'csv'
|
||||
| 'pdf'
|
||||
| 'image-generic';
|
||||
| 'image-generic'
|
||||
| 'audio'
|
||||
| 'xml';
|
||||
|
||||
export interface DefinedTool {
|
||||
type: ToolCategory;
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import { stringTools } from '../pages/tools/string';
|
||||
import { imageTools } from '../pages/tools/image';
|
||||
import { DefinedTool, ToolCategory } from './defineTool';
|
||||
import { capitalizeFirstLetter } from '../utils/string';
|
||||
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';
|
||||
@@ -11,18 +12,22 @@ import { csvTools } from '../pages/tools/csv';
|
||||
import { timeTools } from '../pages/tools/time';
|
||||
import { IconifyIcon } from '@iconify/react';
|
||||
import { pdfTools } from '../pages/tools/pdf';
|
||||
import { xmlTools } from '../pages/tools/xml';
|
||||
|
||||
const toolCategoriesOrder: ToolCategory[] = [
|
||||
'image-generic',
|
||||
'string',
|
||||
'json',
|
||||
'pdf',
|
||||
'string',
|
||||
'video',
|
||||
'time',
|
||||
'audio',
|
||||
'json',
|
||||
'list',
|
||||
'csv',
|
||||
'number',
|
||||
'png',
|
||||
'time',
|
||||
'xml',
|
||||
'gif'
|
||||
];
|
||||
export const tools: DefinedTool[] = [
|
||||
@@ -34,7 +39,9 @@ export const tools: DefinedTool[] = [
|
||||
...csvTools,
|
||||
...videoTools,
|
||||
...numberTools,
|
||||
...timeTools
|
||||
...timeTools,
|
||||
...audioTools,
|
||||
...xmlTools
|
||||
];
|
||||
const categoriesConfig: {
|
||||
type: ToolCategory;
|
||||
@@ -115,6 +122,18 @@ 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: 'ic:twotone-audiotrack',
|
||||
value:
|
||||
'Tools for working with audio – extract audio from video, adjusting audio speed, merging multiple audio files and much more.'
|
||||
},
|
||||
{
|
||||
type: 'xml',
|
||||
icon: 'mdi-light:xml',
|
||||
value:
|
||||
'Tools for working with XML data structures - viewer, beautifier, validator and much more'
|
||||
}
|
||||
];
|
||||
// use for changelogs
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { UpdateField } from '@components/options/ToolOptions';
|
||||
import { getToolsByCategory } from '@tools/index';
|
||||
|
||||
// Here starting the shared values for string manipulation.
|
||||
|
||||
@@ -105,3 +106,7 @@ export function itemCounter(
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
export const getToolCategoryTitle = (categoryName: string): string =>
|
||||
getToolsByCategory().find((category) => category.type === categoryName)!
|
||||
.rawTitle;
|
||||
|
Reference in New Issue
Block a user