Merge branch 'main' into pr/bhavesh158/322

This commit is contained in:
Chesterkxng
2026-04-07 13:46:09 +02:00
124 changed files with 3029 additions and 522 deletions
+2
View File
@@ -12,6 +12,7 @@ import pdf from '../../public/locales/en/pdf.json';
import audio from '../../public/locales/en/audio.json';
import xml from '../../public/locales/en/xml.json';
import image from '../../public/locales/en/image.json';
import converters from '../../public/locales/en/converters.json';
declare module 'i18next' {
interface CustomTypeOptions {
@@ -28,6 +29,7 @@ declare module 'i18next' {
audio: typeof audio;
xml: typeof xml;
image: typeof image;
converters: typeof converters;
};
}
}
+2
View File
@@ -150,6 +150,8 @@ export default function Hero() {
sx={{ mb: 2 }}
autoHighlight
options={filteredTools}
// Disable default MUI filtering since we already apply custom filterTools
filterOptions={(options) => options}
groupBy={(option) => option.type}
renderGroup={(params) => {
return (
@@ -0,0 +1,207 @@
import React, { useRef } from 'react';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import ImageIcon from '@mui/icons-material/Image';
import { useTranslation } from 'react-i18next';
import { heicTo, isHeic } from 'heic-to';
interface MultiImageInputComponentProps {
accept: string[];
title?: string;
type: 'image';
value: MultiImageInput[];
onChange: (files: MultiImageInput[]) => void;
}
export interface MultiImageInput {
file: File;
order: number;
preview?: string;
}
export default function ToolMultiImageInput({
value,
onChange,
accept,
title,
type
}: MultiImageInputComponentProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
const files = event.target.files;
if (!files) return;
const newFiles: MultiImageInput[] = await Promise.all(
Array.from(files).map(async (file, index) => {
let processedFile = file;
try {
if (await isHeic(file)) {
const convertedBlob = await heicTo({
blob: file,
type: 'image/jpeg'
});
processedFile = new File(
[convertedBlob],
file.name.replace(/\.[^/.]+$/, '') + '.jpg',
{ type: 'image/jpeg' }
);
}
} catch (err) {
console.warn('HEIC conversion failed:', file.name, err);
}
return {
file: processedFile,
order: value.length + index,
preview: URL.createObjectURL(processedFile)
};
})
);
onChange([...value, ...newFiles]);
event.target.value = '';
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
value.forEach((f) => {
if (f.preview) URL.revokeObjectURL(f.preview);
});
onChange([]);
}
function handleRemove(index: number) {
const fileToRemove = value[index];
if (fileToRemove?.preview) {
URL.revokeObjectURL(fileToRemove.preview);
}
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}
function fileNameTruncate(fileName: string) {
const maxLength = 10;
return fileName.length > maxLength
? fileName.slice(0, maxLength) + '...'
: fileName;
}
return (
<Box>
<InputHeader
title={
title ||
t('toolMultipleImageInput.inputTitle', {
type: 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',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
width: '120px',
border: 1,
borderRadius: 1,
padding: 1,
position: 'relative'
}}
>
{file.preview ? (
<Box
component="img"
src={file.preview}
alt={file.file.name}
sx={{
width: '100%',
height: '80px',
objectFit: 'cover',
borderRadius: 1
}}
/>
) : (
<ImageIcon sx={{ fontSize: 40 }} />
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
mt: 0.5
}}
>
<Typography variant="caption">
{fileNameTruncate(file.file.name)}
</Typography>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => handleRemove(index)}
>
✖
</Box>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
{t('toolMultipleImageInput.noFilesSelected')}
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple
/>
</Box>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ const CheckboxWithDesc = ({
};
return (
<Box>
<Box mb={3}>
<FormControlLabel
control={
<Checkbox
+2 -2
View File
@@ -27,7 +27,7 @@ const ColorSelector: React.FC<ColorSelectorProps & TextFieldProps> = ({
};
return (
<Box mb={1}>
<Box mb={3}>
<Stack direction={'row'}>
<TextField
sx={{ backgroundColor: 'background.paper' }}
@@ -46,7 +46,7 @@ const ColorSelector: React.FC<ColorSelectorProps & TextFieldProps> = ({
onChange={handleColorChange}
/>
</Stack>
<Typography fontSize={globalDescriptionFontSize}>
<Typography fontSize={globalDescriptionFontSize} mt={1}>
{description}
</Typography>
</Box>
@@ -22,7 +22,7 @@ const RadioWithTextField = ({
radioDescription?: string;
}) => {
return (
<Box>
<Box mb={3}>
<SimpleRadio
checked={checked}
onClick={onRadioClick}
+1 -1
View File
@@ -32,7 +32,7 @@ const SelectWithDesc = <T extends string | boolean>({
};
return (
<Box>
<Box mb={3}>
<Select value={selected} onChange={handleChange}>
{options.map((option) => (
<MenuItem key={option.label} value={option.value.toString()}>
+1 -1
View File
@@ -16,7 +16,7 @@ const TextFieldWithDesc = ({
...props
}: TextFieldProps & OwnProps) => {
return (
<Box>
<Box mb={3}>
<TextField
placeholder={placeholder}
sx={{ backgroundColor: 'background.paper' }}
+2 -1
View File
@@ -15,7 +15,8 @@ export const validNamespaces = [
'audio',
'xml',
'translation',
'image'
'image',
'converters'
] as const satisfies readonly Namespace[];
export type I18nNamespaces = (typeof validNamespaces)[number];
@@ -14,11 +14,8 @@ const initialValues: InitialValuesType = {
outputFormat: 'mp3'
};
export default function AudioConverter({
title,
longDescription
}: ToolComponentProps) {
const { t } = useTranslation('audio');
export default function AudioConverter({ title }: ToolComponentProps) {
const { t } = useTranslation('converters');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
@@ -47,12 +44,7 @@ export default function AudioConverter({
updateField
}) => [
{
id: 'output-format',
title: t('audioConverter.outputFormat', 'Output Format'),
description: t(
'audioConverter.outputFormatDescription',
'Select the desired output audio format'
),
title: t('audioConverter.outputFormat'),
component: (
<Box>
<SelectWithDesc
@@ -62,10 +54,7 @@ export default function AudioConverter({
label: value.toUpperCase(),
value: value as AudioFormat
}))}
description={t(
'audioConverter.outputFormatDescription',
'Select the desired output audio format'
)}
description={t('audioConverter.outputFormatDescription')}
/>
</Box>
)
@@ -80,13 +69,13 @@ export default function AudioConverter({
<ToolAudioInput
value={input}
onChange={setInput}
title={t('audioConverter.uploadAudio', 'Upload Your Audio File')}
title={t('audioConverter.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
value={result}
title={t('audioConverter.outputTitle', 'Converted Audio')}
title={t('audioConverter.outputTitle')}
loading={loading}
/>
}
@@ -95,8 +84,8 @@ export default function AudioConverter({
setInput={setInput}
compute={compute}
toolInfo={{
title: `What is a ${title}?`,
description: longDescription
title: t('audioConverter.title'),
description: t('audioConverter.longDescription')
}}
/>
);
@@ -3,10 +3,10 @@ import { lazy } from 'react';
export const tool = defineTool('converters', {
i18n: {
name: 'translation:converters.audioconverter.title',
description: 'translation:converters.audioconverter.description',
shortDescription: 'translation:converters.audioconverter.shortDescription',
longDescription: 'translation:converters.audioconverter.longDescription'
name: 'converters:audioConverter.title',
description: 'converters:audioConverter.description',
shortDescription: 'converters:audioConverter.shortDescription',
longDescription: 'converters:audioConverter.longDescription'
},
path: 'audio-converter',
icon: 'mdi:music-note-outline',
@@ -0,0 +1,150 @@
import { Box, Slider, Typography } from '@mui/material';
import ColorSelector from 'components/options/ColorSelector';
import ToolFileResult from 'components/result/ToolFileResult';
import React, { useContext, useState, useCallback } from 'react';
import * as Yup from 'yup';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import InitialValuesType from './types';
import ToolMultipleImageInput, {
MultiImageInput
} from '@components/input/ToolMultipleImageInput';
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
import { CustomSnackBarContext } from '../../../../contexts/CustomSnackBarContext';
import { convertToWebp } from './service';
import { useTranslation } from 'react-i18next';
import debounce from 'lodash/debounce';
const initialValues: InitialValuesType = {
backgroundColor: '#ffffff',
quality: 100
};
const validationSchema = Yup.object({
quality: Yup.number().min(1).max(100).required('Quality is required'),
backgroundColor: Yup.string().required('Background color is required'),
maxFileSizeInMb: Yup.number()
.min(1)
.max(100)
.required('Maximum file size is required')
});
export default function ConvertToWebp({ title }: ToolComponentProps) {
const { t } = useTranslation('converters');
const [input, setInput] = useState<MultiImageInput[]>([]);
const [result, setResult] = useState<File[]>([]);
const [zipFile, setZipFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (
optionsValues: typeof initialValues,
input: MultiImageInput[]
) => {
if (!input || input.length === 0) return;
// Running the Service
try {
setIsProcessing(true);
const output = await convertToWebp(
input.map((img) => img.file),
optionsValues.quality,
optionsValues.backgroundColor
);
if (!output) {
showSnackBar(t('convertToWebp.failedToConvert'), 'error');
return;
} else {
setResult(output.convertedFiles);
setZipFile(output.zipFile);
}
} catch (error) {
showSnackBar(`Error converting files: ${error}`, 'error');
setZipFile(null);
} finally {
setIsProcessing(false);
}
console.log('Images are processed');
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolMultipleImageInput
value={input}
type={'image'}
onChange={setInput}
accept={['image/*']}
title={t('convertToWebp.inputTitle')}
/>
}
resultComponent={
zipFile ? (
<ToolMultiFileResult
title={t('convertToWebp.resultTitle')}
value={result}
zipFile={zipFile}
loading={isProcessing}
/>
) : (
<ToolFileResult
title={t('convertToWebp.resultTitle')}
value={result[0] ?? null}
extension={'webp'}
loading={isProcessing}
/>
)
}
initialValues={initialValues}
validationSchema={validationSchema}
getGroups={({ values, updateField }) => [
{
title: t('convertToWebp.options.title'),
component: (
<Box>
<Box mb={3}>
<Typography variant="body2" color="text.secondary" gutterBottom>
{t('convertToWebp.options.quality')} {values.quality}%
</Typography>
<Slider
value={values.quality}
onChange={(_, value) =>
updateField(
'quality',
Array.isArray(value) ? value[0] : value
)
}
min={1}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
sx={{ mt: 1 }}
/>
<Typography variant="caption" color="text.secondary">
{t('convertToWebp.options.qualityDescription')}
</Typography>
</Box>
<ColorSelector
value={values.backgroundColor}
onColorChange={(val) => updateField('backgroundColor', val)}
description={t('convertToWebp.options.colorDescription')}
inputProps={{ 'data-testid': 'background-color-input' }}
/>
</Box>
)
}
]}
compute={debouncedCompute}
setInput={setInput}
/>
);
}
@@ -0,0 +1,16 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('converters', {
i18n: {
name: 'converters:convertToWebp.title',
description: 'converters:convertToWebp.description',
shortDescription: 'converters:convertToWebp.shortDescription'
},
path: 'convert-to-webp',
icon: 'iconoir:webp-format',
keywords: ['convert', 'jpg', 'jpeg', 'png', 'webp', 'compression'],
component: lazy(() => import('./index'))
});
@@ -0,0 +1,105 @@
import JSZip from 'jszip';
export const convertToWebp = async (
files: File[],
quality: number,
backgroundColor: string
): Promise<{ convertedFiles: File[]; zipFile: File | null } | null> => {
try {
// Map Out the files
const output = await Promise.all(
files.map(async (file) => {
try {
// Process Images One by One
const processedImage = await processImage(
file,
quality,
backgroundColor
);
return processedImage;
} catch (error) {
console.error(`Error converting ${file.name} to WEBP:`, error);
return null;
}
})
);
// Filter Out The Valid Files
const convertedFiles = output.filter((f): f is File => f !== null);
// Checking the Need to Convert
if (convertedFiles.length === 0) return null;
if (convertedFiles.length === 1) return { convertedFiles, zipFile: null };
// Converting Into Zip File
const zip = new JSZip();
convertedFiles.forEach((file) => zip.file(file.name, file));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFile = new File([zipBlob], 'compressed-images.zip', {
type: 'application/zip'
});
if (convertedFiles) {
return { convertedFiles, zipFile };
} else {
return null;
}
} catch (error) {
throw new Error(error as string);
}
};
// Process and Return An Image
async function processImage(
file: File,
quality: number,
backgroundColor: string
): Promise<File | null> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return null;
const img = new Image();
img.src = URL.createObjectURL(file);
try {
await img.decode();
canvas.width = img.width;
canvas.height = img.height;
// Fill background with selected color (important for transparency)
let bgColor: [number, number, number];
try {
//@ts-ignore
bgColor = Color(backgroundColor).rgb().array();
} catch (err) {
bgColor = [255, 255, 255]; // Default to white
}
ctx.fillStyle = `rgb(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw the image on top
ctx.drawImage(img, 0, 0);
const fileName = file.name.replace(/\.[^/.]+$/, '') + '.webp';
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject('Canvas toBlob returned null')),
'image/webp',
quality / 100
);
});
return new File([blob], fileName, { type: 'image/webp' });
} catch (error) {
console.error(`Error converting ${file.name} to WEBP:`, error);
return null;
} finally {
URL.revokeObjectURL(img.src);
}
}
@@ -0,0 +1,4 @@
export default interface InitialValuesType {
backgroundColor: string;
quality: number;
}
+5 -1
View File
@@ -1,3 +1,7 @@
import { tool as convertersAudioConverter } from './audio-converter/meta';
import { tool as convertersWebpConverter } from './convert-to-webp/meta';
export const convertersTools = [convertersAudioConverter];
export const convertersTools = [
convertersAudioConverter,
convertersWebpConverter
];
+1 -1
View File
@@ -11,6 +11,6 @@ export const tool = defineTool('csv', {
path: 'csv-to-xml',
icon: 'mdi-light:xml',
keywords: ['csv', 'xml', 'convert', 'transform', 'parse'],
keywords: ['convert', 'transform', 'parse'],
component: lazy(() => import('./index'))
});
+1 -1
View File
@@ -11,6 +11,6 @@ export const tool = defineTool('csv', {
path: 'csv-to-yaml',
icon: 'nonicons:yaml-16',
keywords: ['csv', 'to', 'yaml'],
keywords: ['comma', 'separated', 'values', 'transform', 'markup', 'convert'],
component: lazy(() => import('./index'))
});
+3 -1
View File
@@ -7,6 +7,7 @@ import { tool as csvToJson } from './csv-to-json/meta';
import { tool as csvToXml } from './csv-to-xml/meta';
import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
import { tool as csvToTsv } from './csv-to-tsv/meta';
import { tool as tsvToJson } from './tsv-to-json/meta';
import { tool as swapCsvColumns } from './swap-csv-columns/meta';
export const csvTools = [
@@ -19,5 +20,6 @@ export const csvTools = [
ChangeCsvDelimiter,
findIncompleteCsvRecords,
transposeCsv,
insertCsvColumns
insertCsvColumns,
tsvToJson
];
@@ -1,15 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('json', {
export const tool = defineTool('csv', {
path: 'tsv-to-json',
icon: 'material-symbols:code',
keywords: ['tsv', 'json', 'convert', 'tabular'],
component: lazy(() => import('./index')),
i18n: {
name: 'json:tsvToJson.title',
description: 'json:tsvToJson.description',
shortDescription: 'json:tsvToJson.shortDescription'
name: 'csv:tsvToJson.title',
description: 'csv:tsvToJson.description',
shortDescription: 'csv:tsvToJson.shortDescription'
}
});
@@ -1,6 +1,6 @@
import { InitialValuesType } from './types';
import { beautifyJson } from '../prettify/service';
import { minifyJson } from '../minify/service';
import { beautifyJson } from '../../json/prettify/service';
import { minifyJson } from '../../json/minify/service';
export function convertTsvToJson(
input: string,
@@ -11,6 +11,6 @@ export const tool = defineTool('image-generic', {
path: 'change-colors',
icon: 'cil:color-fill',
keywords: ['change', 'colors', 'in', 'png', 'image', 'jpg'],
keywords: ['png', 'jpg', 'replace', 'swap'],
component: lazy(() => import('./index'))
});
@@ -1,11 +1,14 @@
import React, { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InitialValuesType } from './types';
import { compressImage } from './service';
import { compressImages } from './service';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolMultipleImageInput, {
MultiImageInput
} from '@components/input/ToolMultipleImageInput';
import { ToolComponentProps } from '@tools/defineTool';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { Box } from '@mui/material';
import Typography from '@mui/material/Typography';
@@ -19,28 +22,42 @@ const initialValues: InitialValuesType = {
export default function CompressImage({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [input, setInput] = useState<MultiImageInput[]>([]);
const [results, setResults] = useState<File[]>([]);
const [zipFile, setZipFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [originalSize, setOriginalSize] = useState<number | null>(null); // Store original file size
const [compressedSize, setCompressedSize] = useState<number | null>(null); // Store compressed file size
const { showSnackBar } = useContext(CustomSnackBarContext);
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
const compute = async (
values: InitialValuesType,
input: MultiImageInput[]
) => {
if (!input.length) return;
setOriginalSize(input.reduce((acc, img) => acc + img.file.size, 0));
setOriginalSize(input.size);
try {
setIsProcessing(true);
const compressed = await compressImage(input, values);
const output = await compressImages(
input.map((img) => img.file),
values
);
if (compressed) {
setResult(compressed);
setCompressedSize(compressed.size);
} else {
if (!output) {
showSnackBar(t('compress.failedToCompress'), 'error');
return;
}
if (output.results.length < input.length) {
showSnackBar(t('compress.failedToCompress'), 'error');
}
setResults(output.results);
setZipFile(output.zipFile);
setCompressedSize(output.results.reduce((acc, f) => acc + f.size, 0));
} catch (err) {
console.error('Error in compression:', err);
} finally {
@@ -53,19 +70,29 @@ export default function CompressImage({ title }: ToolComponentProps) {
title={title}
input={input}
inputComponent={
<ToolImageInput
<ToolMultipleImageInput
value={input}
type={'image'}
onChange={setInput}
accept={['image/*']}
title={t('compress.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('compress.resultTitle')}
value={result}
loading={isProcessing}
/>
zipFile ? (
<ToolMultiFileResult
title={t('compress.resultTitle')}
value={results}
zipFile={zipFile}
loading={isProcessing}
/>
) : (
<ToolFileResult
title={t('compress.resultTitle')}
value={results[0] ?? null}
extension={results[0]?.name.split('.').pop() || 'png'}
/>
)
}
initialValues={initialValues}
getGroups={({ values, updateField }) => [
@@ -1,10 +1,11 @@
import { InitialValuesType } from './types';
import imageCompression from 'browser-image-compression';
import JSZip from 'jszip';
export const compressImage = async (
file: File,
export const compressImages = async (
files: File[],
options: InitialValuesType
): Promise<File | null> => {
): Promise<{ results: File[]; zipFile: File | null } | null> => {
try {
const { maxFileSizeInMB, quality } = options;
@@ -16,15 +17,40 @@ export const compressImage = async (
initialQuality: quality / 100 // Convert percentage to decimal
};
// Compress the image
const compressedFile = await imageCompression(file, compressionOptions);
// Compress the given images
const compressed = await Promise.all(
files.map(async (file) => {
try {
const compressedFile = await imageCompression(
file,
compressionOptions
);
return new File([compressedFile], file.name, {
type: compressedFile.type
});
} catch (error) {
console.error(`Error compressing ${file.name}:`, error);
return null;
}
})
);
// Create a new file with the original name
return new File([compressedFile], file.name, {
type: compressedFile.type
const results = compressed.filter((f): f is File => f !== null);
if (results.length === 0) return null;
if (results.length === 1) return { results, zipFile: null };
const zip = new JSZip();
results.forEach((file) => zip.file(file.name, file));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFile = new File([zipBlob], 'compressed-images.zip', {
type: 'application/zip'
});
return { results, zipFile };
} catch (error) {
console.error('Error compressing image:', error);
console.error('Error compressing images:', error);
return null;
}
};
@@ -12,10 +12,8 @@ export const tool = defineTool('image-generic', {
icon: 'mdi:text-recognition', // Iconify icon as a string
keywords: [
'ocr',
'optical character recognition',
'image to text',
'extract text',
'extract',
'scan',
'tesseract',
'jpg',
+1
View File
@@ -10,6 +10,7 @@ import { tool as qrCodeGenerator } from './qr-code/meta';
import { tool as rotateImage } from './rotate/meta';
import { tool as convertToJpg } from './convert-to-jpg/meta';
import { tool as imageEditor } from './editor/meta';
export const imageGenericTools = [
imageEditor,
resizeImage,
+55 -60
View File
@@ -1,13 +1,15 @@
import React, { useCallback, useState } from 'react';
import { Box, MenuItem, TextField } from '@mui/material';
import { Box } from '@mui/material';
import * as Yup from 'yup';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import SelectWithDesc from '@components/options/SelectWithDesc';
import { InitialValuesType, QRCodeType, WifiEncryptionType } from './types';
import ColorSelector from '@components/options/ColorSelector';
import ToolFileResult from '@components/result/ToolFileResult';
import { useTranslation } from 'react-i18next';
import * as QRCode from 'qrcode';
import { debounce } from 'lodash';
@@ -166,6 +168,7 @@ const validationSchema = Yup.object().shape({
});
export default function QRCodeGenerator({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [result, setResult] = useState<File | null>(null);
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
@@ -176,24 +179,22 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
title: 'QR Code Type',
component: (
<Box>
<TextField
select
fullWidth
value={values.qrCodeType}
onChange={(e) =>
updateField('qrCodeType', e.target.value as QRCodeType)
<SelectWithDesc
selected={values.qrCodeType}
onChange={(value) =>
updateField('qrCodeType', value as QRCodeType)
}
label="Select QR Code Type"
margin="normal"
>
<MenuItem value="URL">URL</MenuItem>
<MenuItem value="Text">Text</MenuItem>
<MenuItem value="Email">Email</MenuItem>
<MenuItem value="Phone">Phone</MenuItem>
<MenuItem value="SMS">SMS</MenuItem>
<MenuItem value="WiFi">WiFi</MenuItem>
<MenuItem value="vCard">vCard (Contact)</MenuItem>
</TextField>
options={[
{ label: 'URL', value: 'URL' },
{ label: 'Text', value: 'Text' },
{ label: 'Email', value: 'Email' },
{ label: 'Phone', value: 'Phone' },
{ label: 'SMS', value: 'SMS' },
{ label: 'WiFi', value: 'WiFi' },
{ label: 'vCard', value: 'vCard' }
]}
description={t('qrCode.options.qrType')}
/>
</Box>
)
},
@@ -204,7 +205,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.size}
onOwnChange={(val) => updateField('size', val)}
description="Size in pixels (100-1000)"
description={t('qrCode.options.size')}
inputProps={{
type: 'number',
min: 100,
@@ -233,7 +234,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.url}
onOwnChange={(val) => updateField('url', val)}
description="Enter the URL"
description={t('qrCode.options.url')}
inputProps={{
placeholder: 'https://example.com'
}}
@@ -244,11 +245,11 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.text}
onOwnChange={(val) => updateField('text', val)}
description="Enter the text"
description={t('qrCode.options.text')}
multiline
rows={4}
inputProps={{
placeholder: 'Enter your text here'
placeholder: 'Lorem Ipsum'
}}
/>
)}
@@ -258,7 +259,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.emailAddress}
onOwnChange={(val) => updateField('emailAddress', val)}
description="Email Address"
description={t('qrCode.options.email')}
inputProps={{
placeholder: 'example@example.com',
type: 'email'
@@ -267,7 +268,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.emailSubject}
onOwnChange={(val) => updateField('emailSubject', val)}
description="Email Subject (optional)"
description={t('qrCode.options.emailSubject')}
inputProps={{
placeholder: 'Subject line'
}}
@@ -275,7 +276,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.emailBody}
onOwnChange={(val) => updateField('emailBody', val)}
description="Email Body (optional)"
description={t('qrCode.options.emailBody')}
multiline
rows={4}
inputProps={{
@@ -289,7 +290,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.phoneNumber}
onOwnChange={(val) => updateField('phoneNumber', val)}
description="Phone Number"
description={t('qrCode.options.phone')}
inputProps={{
placeholder: '+1234567890',
type: 'tel'
@@ -302,7 +303,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.smsNumber}
onOwnChange={(val) => updateField('smsNumber', val)}
description="Phone Number"
description={t('qrCode.options.phone')}
inputProps={{
placeholder: '+1234567890',
type: 'tel'
@@ -311,11 +312,11 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.smsMessage}
onOwnChange={(val) => updateField('smsMessage', val)}
description="Message (optional)"
description={t('qrCode.options.message')}
multiline
rows={4}
inputProps={{
placeholder: 'Your message here'
placeholder: 'Lorem Ipsum'
}}
/>
</>
@@ -326,37 +327,32 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.wifiSsid}
onOwnChange={(val) => updateField('wifiSsid', val)}
description="Network Name (SSID)"
description={t('qrCode.options.ssid')}
inputProps={{
placeholder: 'Network name'
placeholder: 'WIFI name'
}}
/>
<TextFieldWithDesc
value={values.wifiPassword}
onOwnChange={(val) => updateField('wifiPassword', val)}
description="Password"
description={t('qrCode.options.password')}
inputProps={{
placeholder: 'Password',
placeholder: '******',
type: 'password'
}}
/>
<TextField
select
fullWidth
value={values.wifiEncryption}
onChange={(e) =>
updateField(
'wifiEncryption',
e.target.value as WifiEncryptionType
)
<SelectWithDesc
selected={values.wifiEncryption}
onChange={(value) =>
updateField('wifiEncryption', value as WifiEncryptionType)
}
label="Encryption Type"
margin="normal"
>
<MenuItem value="WPA">WPA</MenuItem>
<MenuItem value="WEP">WEP</MenuItem>
<MenuItem value="None">None</MenuItem>
</TextField>
options={[
{ label: 'WPA', value: 'WPA' },
{ label: 'WEP', value: 'WEP' },
{ label: 'None', value: 'None' }
]}
description={t('qrCode.options.encryptionType')}
/>
</>
)}
@@ -365,7 +361,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardName}
onOwnChange={(val) => updateField('vCardName', val)}
description="Full Name"
description={t('qrCode.options.fullName')}
inputProps={{
placeholder: 'John Doe'
}}
@@ -373,7 +369,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardEmail}
onOwnChange={(val) => updateField('vCardEmail', val)}
description="Email"
description={t('qrCode.options.email')}
inputProps={{
placeholder: 'john@example.com',
type: 'email'
@@ -382,7 +378,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardPhone}
onOwnChange={(val) => updateField('vCardPhone', val)}
description="Phone"
description={t('qrCode.options.phone')}
inputProps={{
placeholder: '+1234567890',
type: 'tel'
@@ -391,7 +387,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardAddress}
onOwnChange={(val) => updateField('vCardAddress', val)}
description="Address"
description={t('qrCode.options.address')}
inputProps={{
placeholder: '123 Main St, City, Country'
}}
@@ -399,7 +395,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardCompany}
onOwnChange={(val) => updateField('vCardCompany', val)}
description="Company (optional)"
description={t('qrCode.options.company')}
inputProps={{
placeholder: 'Company name'
}}
@@ -407,7 +403,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardTitle}
onOwnChange={(val) => updateField('vCardTitle', val)}
description="Job Title (optional)"
description={t('qrCode.options.job')}
inputProps={{
placeholder: 'Software Developer'
}}
@@ -415,7 +411,7 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
<TextFieldWithDesc
value={values.vCardWebsite}
onOwnChange={(val) => updateField('vCardWebsite', val)}
description="Website (optional)"
description={t('qrCode.options.website')}
inputProps={{
placeholder: 'https://example.com'
}}
@@ -460,12 +456,11 @@ export default function QRCodeGenerator({ title }: ToolComponentProps) {
validationSchema={validationSchema}
compute={debouncedCompute}
resultComponent={
<ToolFileResult title={'Generated QR code'} value={result} />
<ToolFileResult title={t('qrCode.resultOutput')} value={result} />
}
toolInfo={{
title: 'QR Code Generator',
description:
'Generate QR codes for different data types: URL, Text, Email, Phone, SMS, WiFi, vCard, and more. Customize the size and colors to create the perfect QR code for your needs.'
title: t('qrCode.title'),
description: t('qrCode.description')
}}
/>
);
@@ -11,8 +11,9 @@ export const tool = defineTool('image-generic', {
path: 'qr-code',
icon: 'mdi:qrcode', // Iconify icon as a string
keywords: [
'qr code',
'qrcode',
'quick',
'read',
'generator',
'url',
'text',
@@ -21,7 +22,8 @@ export const tool = defineTool('image-generic', {
'sms',
'wifi',
'vcard',
'contact'
'contact',
'barcode'
],
component: lazy(() => import('./index'))
});
@@ -11,14 +11,6 @@ export const tool = defineTool('image-generic', {
path: 'remove-background',
icon: 'mdi:image-remove',
keywords: [
'remove',
'background',
'png',
'transparent',
'image',
'ai',
'jpg'
],
keywords: ['png', 'transparent', 'image', 'ai', 'jpg', 'backing', 'backdrop'],
component: lazy(() => import('./index'))
});
+56 -14
View File
@@ -1,15 +1,18 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import SimpleRadio from '@components/options/SimpleRadio';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import { processImage } from './service';
import ToolMultipleImageInput, {
MultiImageInput
} from '@components/input/ToolMultipleImageInput';
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
import ToolFileResult from '@components/result/ToolFileResult';
import { resizeImages } from './service';
import { InitialValuesType } from './types';
import { useTranslation } from 'react-i18next';
@@ -47,12 +50,35 @@ const validationSchema = Yup.object({
export default function ResizeImage({ title }: ToolComponentProps) {
const { t } = useTranslation('image');
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [input, setInput] = useState<MultiImageInput[]>([]);
const [results, setResults] = useState<File[]>([]);
const [zipFile, setZipFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (optionsValues: InitialValuesType, input: any) => {
const compute = async (
optionsValues: InitialValuesType,
input: MultiImageInput[]
) => {
if (!input) return;
setResult(await processImage(input, optionsValues));
try {
setIsProcessing(true);
const output = await resizeImages(
input.map((img) => img.file),
optionsValues
);
if (!output) {
return;
}
setResults(output.results);
setZipFile(output.zipFile);
} catch (err) {
console.error('Error in resizing:', err);
} finally {
setIsProcessing(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> = ({
@@ -171,19 +197,35 @@ export default function ResizeImage({ title }: ToolComponentProps) {
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
<ToolMultipleImageInput
value={input}
onChange={setInput}
accept={['image/jpeg', 'image/png', 'image/svg+xml', 'image/gif']}
type="image"
accept={[
'image/jpeg',
'image/png',
'image/svg+xml',
'image/gif',
'image/webp'
]}
title={t('resize.inputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('resize.resultTitle')}
value={result}
extension={input?.name.split('.').pop() || 'png'}
/>
zipFile ? (
<ToolMultiFileResult
title={t('resize.resultTitle')}
value={results}
zipFile={zipFile}
loading={isProcessing}
/>
) : (
<ToolFileResult
title={t('resize.resultTitle')}
value={results[0] ?? null}
extension={results[0]?.name.split('.').pop() || 'png'}
/>
)
}
toolInfo={{
title: t('resize.toolInfo.title'),
@@ -1,8 +1,9 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
import { fetchFile } from '@ffmpeg/util';
import JSZip from 'jszip';
export const processImage = async (
const processImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
@@ -216,3 +217,39 @@ export const processImage = async (
}, outputType);
});
};
export const resizeImages = async (
files: File[],
options: InitialValuesType
): Promise<{ results: File[]; zipFile: File | null } | null> => {
try {
const processed = await Promise.all(
files.map(async (file) => {
try {
return await processImage(file, options);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
return null;
}
})
);
const results = processed.filter((f): f is File => f !== null);
if (results.length === 0) return null;
if (results.length === 1) return { results, zipFile: null };
const zip = new JSZip();
results.forEach((file) => zip.file(file.name, file));
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFile = new File([zipBlob], 'resized-images.zip', {
type: 'application/zip'
});
return { results, zipFile };
} catch (error) {
console.error('Error processing images:', error);
return null;
}
};
@@ -6,7 +6,6 @@ import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
import ToolFileResult from 'components/result/ToolFileResult';
import Color from 'color';
import React, { useState } from 'react';
import * as Yup from 'yup';
import { areColorsSimilar } from 'utils/color';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
@@ -16,9 +15,7 @@ const initialValues = {
color: 'white',
similarity: '10'
};
const validationSchema = Yup.object({
// splitSeparator: Yup.string().required('The separator is required')
});
export default function ConvertJgpToPng({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
+2 -2
View File
@@ -4,8 +4,8 @@ import { tool as jsonStringify } from './stringify/meta';
import { tool as validateJson } from './validateJson/meta';
import { tool as jsonToXml } from './json-to-xml/meta';
import { tool as escapeJson } from './escape-json/meta';
import { tool as tsvToJson } from './tsv-to-json/meta';
import { tool as jsonComparison } from './json-comparison/meta';
import { tool as jsonToCsv } from './json-to-csv/meta';
export const jsonTools = [
validateJson,
@@ -13,7 +13,7 @@ export const jsonTools = [
jsonMinify,
jsonStringify,
jsonToXml,
jsonToCsv,
escapeJson,
tsvToJson,
jsonComparison
];
+165
View File
@@ -0,0 +1,165 @@
import { useState } from 'react';
import ToolContent from '@components/ToolContent';
import ToolCodeInput from '@components/input/ToolCodeInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { convertJsonToCsv } from './service';
import { CardExampleType } from '@components/examples/ToolExamples';
import { ToolComponentProps } from '@tools/defineTool';
import { Box } from '@mui/material';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import { InitialValuesType } from './types';
import { useTranslation } from 'react-i18next';
const initialValues: InitialValuesType = {
delimiter: ',',
includeHeaders: true,
quoteStrings: 'auto'
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Array of objects',
description:
'Convert multiple JSON objects into CSV rows, one row per object.',
sampleText: `[
{ "name": "John Doe", "age": 25, "city": "New York" },
{ "name": "Jane Doe", "age": 30, "city": "Los Angeles" },
{ "name": "Bob Smith", "age": 22, "city": "Chicago" }
]`,
sampleResult: `name,age,city\nJohn Doe,25,New York\nJane Doe,30,Los Angeles\nBob Smith,22,Chicago`,
sampleOptions: {
...initialValues
}
},
{
title: 'Nested object (dot notation)',
description:
'Nested keys are flattened using dot notation (e.g. address.city).',
sampleText: `[
{
"name": "John Doe",
"age": 25,
"address": {
"street": "123 Main St",
"city": "New York",
"state": "NY",
"postalCode": "10001"
},
"hobbies": ["reading", "running"]
}
]`,
sampleResult: `name,age,address.street,address.city,address.state,address.postalCode,hobbies[0],hobbies[1]\nJohn Doe,25,123 Main St,New York,NY,10001,reading,running`,
sampleOptions: {
...initialValues
}
},
{
title: 'Sparse rows',
description:
'Missing keys are filled with empty values to keep columns aligned.',
sampleText: `[
{ "name": "Alice", "age": 30 },
{ "name": "Bob", "city": "Paris" },
{ "name": "Carol", "age": 25, "city": "Rome" }
]`,
sampleResult: `name,age,city\nAlice,30,\nBob,,Paris\nCarol,25,Rome`,
sampleOptions: {
...initialValues
}
}
];
export default function JsonToCsv({ title }: ToolComponentProps) {
const { t } = useTranslation('json');
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (values: InitialValuesType, input: string) => {
if (input) {
try {
const csvResult = convertJsonToCsv(input, values);
setResult(csvResult);
} catch (error) {
setResult(
`Error: ${
error instanceof Error ? error.message : 'Invalid JSON format'
}`
);
}
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
exampleCards={exampleCards}
inputComponent={
<ToolCodeInput
title={t('jsonToCsv.inputTitle')}
value={input}
onChange={setInput}
language="json"
/>
}
resultComponent={
<ToolTextResult
title={t('jsonToCsv.outputTitle')}
value={result}
extension={'csv'}
/>
}
getGroups={({ values, updateField }) => [
{
title: t('jsonToCsv.delimiterOption'),
component: (
<Box>
<TextFieldWithDesc
description={t('jsonToCsv.options.delimiter')}
value={values.delimiter}
onOwnChange={(val) => updateField('delimiter', val)}
/>
</Box>
)
},
{
title: t('jsonToCsv.quotingOption'),
component: (
<Box>
<SimpleRadio
checked={values.quoteStrings === 'auto'}
title={t('jsonToCsv.options.autoQuote.label')}
description={t('jsonToCsv.options.autoQuote.description')}
onClick={() => updateField('quoteStrings', 'auto')}
/>
<SimpleRadio
checked={values.quoteStrings === 'always'}
title={t('jsonToCsv.options.alwaysQuote.label')}
description={t('jsonToCsv.options.alwaysQuote.description')}
onClick={() => updateField('quoteStrings', 'always')}
/>
</Box>
)
},
{
title: t('jsonToCsv.headerOption'),
component: (
<Box>
<CheckboxWithDesc
checked={values.includeHeaders}
onChange={(value) => updateField('includeHeaders', value)}
title={t('jsonToCsv.options.header.label')}
description={t('jsonToCsv.options.header.description')}
/>
</Box>
)
}
]}
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('json', {
path: 'json-to-csv',
icon: 'material-symbols:code',
keywords: [],
component: lazy(() => import('./index')),
i18n: {
name: 'json:jsonToCsv.title',
description: 'json:jsonToCsv.description',
shortDescription: 'json:jsonToCsv.shortDescription',
userTypes: ['generalUsers', 'developers']
}
});
@@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import { convertJsonToCsv } from './service';
const defaultOptions: Parameters<typeof convertJsonToCsv>[1] = {
delimiter: ',',
includeHeaders: true,
quoteStrings: 'auto'
};
describe('convertJsonToCsv', () => {
describe('basic conversion', () => {
it('converts a flat array of objects', () => {
const input = JSON.stringify([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,30\r\nBob,25`
);
});
it('converts a single object', () => {
const input = JSON.stringify({ name: 'Alice', age: 30 });
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,30`
);
});
it('excludes header row when includeHeaders is false', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, includeHeaders: false })
).toBe(`Alice,30`);
});
});
describe('flattening', () => {
it('flattens nested objects using dot notation', () => {
const input = JSON.stringify([
{ name: 'Alice', address: { city: 'Paris', zip: '75000' } }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,address.city,address.zip\r\nAlice,Paris,75000`
);
});
it('flattens arrays using index notation', () => {
const input = JSON.stringify([
{ name: 'Alice', tags: ['admin', 'user'] }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,tags[0],tags[1]\r\nAlice,admin,user`
);
});
it('flattens deeply nested structures', () => {
const input = JSON.stringify([
{ user: { address: { geo: { lat: 48.8566, lng: 2.3522 } } } }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`user.address.geo.lat,user.address.geo.lng\r\n48.8566,2.3522`
);
});
});
describe('sparse rows', () => {
it('fills missing keys with empty values', () => {
const input = JSON.stringify([
{ name: 'Alice', age: 30 },
{ name: 'Bob', city: 'Paris' }
]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age,city\r\nAlice,30,\r\nBob,,Paris`
);
});
it('filters out empty objects', () => {
const input = JSON.stringify([{}, { name: 'Alice' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(`name\r\nAlice`);
});
});
describe('quoting', () => {
it('quotes cells containing the delimiter', () => {
const input = JSON.stringify([{ name: 'Smith, John' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name\r\n"Smith, John"`
);
});
it('escapes double-quotes by doubling them', () => {
const input = JSON.stringify([{ name: 'He said "hello"' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name\r\n"He said ""hello"""`
);
});
it('quotes all cells when quoteStrings is always', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, quoteStrings: 'always' })
).toBe(`"name","age"\r\n"Alice","30"`);
});
it('quotes cells containing newlines', () => {
const input = JSON.stringify([{ notes: 'line1\nline2' }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`notes\r\n"line1\nline2"`
);
});
});
describe('delimiters', () => {
it('uses semicolon as delimiter', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, delimiter: ';' })
).toBe(`name;age\r\nAlice;30`);
});
it('uses tab as delimiter', () => {
const input = JSON.stringify([{ name: 'Alice', age: 30 }]);
expect(
convertJsonToCsv(input, { ...defaultOptions, delimiter: '\t' })
).toBe(`name\tage\r\nAlice\t30`);
});
});
describe('null and undefined values', () => {
it('converts null values to empty strings', () => {
const input = JSON.stringify([{ name: 'Alice', age: null }]);
expect(convertJsonToCsv(input, defaultOptions)).toBe(
`name,age\r\nAlice,`
);
});
});
describe('errors', () => {
it('throws on invalid JSON', () => {
expect(() => convertJsonToCsv('invalid json', defaultOptions)).toThrow(
'Invalid JSON input.'
);
});
it('throws on bare primitive', () => {
expect(() => convertJsonToCsv('42', defaultOptions)).toThrow(
'JSON input must be an object or array of objects, not a bare primitive.'
);
});
it('throws when no data rows are found', () => {
expect(() =>
convertJsonToCsv(JSON.stringify([{}, {}]), defaultOptions)
).toThrow('No data found in the provided JSON.');
});
});
});
+120
View File
@@ -0,0 +1,120 @@
import { InitialValuesType } from './types';
import { getJsonHeaders } from 'utils/json';
/**
* Recursively flattens any JSON value into a flat object.
* Objects → dot notation
* Arrays → index notation
*/
function flattenRecursive(
value: unknown,
prefix = '',
result: Record<string, string> = {}
): Record<string, string> {
if (value === null || value === undefined) {
if (prefix) result[prefix] = '';
return result;
}
if (typeof value !== 'object') {
if (prefix) result[prefix] = String(value);
return result;
}
if (Array.isArray(value)) {
value.forEach((item, index) => {
const newKey = prefix ? `${prefix}[${index}]` : `[${index}]`;
flattenRecursive(item, newKey, result);
});
return result;
}
for (const [key, val] of Object.entries(value)) {
const newKey = prefix ? `${prefix}.${key}` : key;
flattenRecursive(val, newKey, result);
}
return result;
}
/**
* Converts any JSON structure into row objects.
*/
function flattenToRows(json: unknown): Record<string, string>[] {
if (Array.isArray(json)) {
return json.map((item) => flattenRecursive(item));
}
if (typeof json === 'object' && json !== null) {
return [flattenRecursive(json)];
}
throw new Error(
'JSON input must be an object or array of objects, not a bare primitive.'
);
}
/**
* Escapes and quotes CSV cells according to options
*/
function quoteCell(value: string, options: InitialValuesType): string {
const { delimiter, quoteStrings } = options;
const escaped = value.replace(/"/g, '""');
const needsQuoting =
value.includes(delimiter) ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r');
if (quoteStrings === 'always') return `"${escaped}"`;
return needsQuoting ? `"${escaped}"` : value;
}
/**
* Converts JSON string to CSV
*/
export function convertJsonToCsv(
input: string,
options: InitialValuesType
): string {
const { delimiter, includeHeaders } = options;
if (!delimiter) throw new Error('No CSV delimiter.');
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
throw new Error('Invalid JSON input.');
}
const rows = flattenToRows(parsed).filter(
(row) => Object.keys(row).length > 0
);
if (rows.length === 0) {
throw new Error('No data found in the provided JSON.');
}
const headers = getJsonHeaders(rows);
const lines: string[] = [];
if (includeHeaders) {
lines.push(headers.map((h) => quoteCell(h, options)).join(delimiter));
}
for (const row of rows) {
const line = headers
.map((header) => quoteCell(row[header] ?? '', options))
.join(delimiter);
lines.push(line);
}
return lines.join('\r\n');
}
@@ -0,0 +1,5 @@
export type InitialValuesType = {
delimiter: string;
includeHeaders: boolean;
quoteStrings: 'always' | 'auto';
};
@@ -2,17 +2,7 @@ import type { GenericCalcType } from './types';
const areaSphere: GenericCalcType = {
icon: 'ph:sphere-duotone',
keywords: [
'sphere',
'area',
'surface area',
'geometry',
'mathematics',
'radius',
'calculation',
'3D',
'shape'
],
keywords: ['geometry', 'radius', '3d'],
path: 'area-sphere',
formula: 'A = 4 * pi * r**2',
presets: [],
@@ -2,18 +2,7 @@ import type { GenericCalcType } from './types';
const volumeSphere: GenericCalcType = {
icon: 'gravity-ui:sphere',
keywords: [
'sphere',
'volume',
'geometry',
'mathematics',
'radius',
'diameter',
'calculation',
'3D',
'shape',
'capacity'
],
keywords: ['geometry', 'radius', 'diameter', 'capacity', '3d'],
i18n: {
name: 'number:sphereVolume.title',
description: 'number:sphereVolume.description',
@@ -4,18 +4,7 @@ import wire_gauge from '../../../../../datatables/data/wire_gauge';
const voltageDropInWire: GenericCalcType = {
icon: 'simple-icons:wire',
keywords: [
'voltage drop',
'cable',
'wire',
'electrical',
'resistance',
'power loss',
'conductor',
'resistivity',
'AWG',
'gauge'
],
keywords: ['awg', 'gauge', 'resistivity', 'conductor'],
path: 'cable-voltage-drop',
formula: 'x = (((p * L) / (A/10**6) ) *2) * I',
i18n: {
+1 -1
View File
@@ -19,7 +19,7 @@ allGenericCalcs.forEach((x) => {
defineTool('number', {
...x,
path: 'generic-calc/' + x.path,
keywords: ['calculator', 'math', ...x.keywords],
keywords: [...x.keywords],
component: lazy(importComponent2)
})
);
+1 -3
View File
@@ -6,13 +6,11 @@ export const tool = defineTool('pdf', {
icon: 'material-symbols:compress',
keywords: [
'pdf',
'compress',
'reduce',
'size',
'optimize',
'shrink',
'file size',
'file',
'ghostscript',
'secure',
'private',
@@ -1,38 +0,0 @@
import { render, screen, fireEvent } from '@testing-library/react';
import ConvertToPdf from './index';
import { vi } from 'vitest';
import '@testing-library/jest-dom';
describe('ConvertToPdf', () => {
it('renders with default state values (full, portrait hidden, no scale shown)', () => {
render(<ConvertToPdf title="Test PDF" />);
expect(screen.getByLabelText(/Full Size \(Same as Image\)/i)).toBeChecked();
expect(screen.queryByLabelText(/A4 Page/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/Portrait/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Scale image:/i)).not.toBeInTheDocument();
});
it('switches to A4 page type and shows orientation and scale', () => {
render(<ConvertToPdf title="Test PDF" />);
const a4Option = screen.getByLabelText(/A4 Page/i);
fireEvent.click(a4Option);
expect(a4Option).toBeChecked();
expect(screen.getByLabelText(/Portrait/i)).toBeChecked();
expect(screen.getByText(/Scale image:\s*100%/i)).toBeInTheDocument();
});
it('updates scale when slider moves (after switching to A4)', () => {
render(<ConvertToPdf title="Test PDF" />);
fireEvent.click(screen.getByLabelText(/A4 Page/i));
const slider = screen.getByRole('slider');
fireEvent.change(slider, { target: { value: 80 } });
expect(screen.getByText(/Scale image:\s*80%/i)).toBeInTheDocument();
});
});
+73 -46
View File
@@ -9,63 +9,71 @@ import {
} from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import ToolImageInput from 'components/input/ToolImageInput';
import ToolMultipleImageInput, {
MultiImageInput
} from '@components/input/ToolMultipleImageInput';
import ToolFileResult from 'components/result/ToolFileResult';
import { ToolComponentProps } from '@tools/defineTool';
import { FormValues, Orientation, PageType, initialValues } from './types';
import { Orientation, PageType, InitialValuesType, ImageSize } from './types';
import { buildPdf } from './service';
import { useTranslation } from 'react-i18next';
const initialFormValues: FormValues = initialValues;
const initialValues: InitialValuesType = {
pageType: 'full',
orientation: 'portrait',
scale: 100
};
export default function ConvertToPdf({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const { t } = useTranslation('pdf');
const [input, setInput] = useState<MultiImageInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [imageSize, setImageSize] = useState<{
widthMm: number;
heightMm: number;
widthPx: number;
heightPx: number;
} | null>(null);
const [imageSizes, setImageSizes] = useState<ImageSize[] | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const compute = async (values: FormValues) => {
if (!input) return;
const { pdfFile, imageSize } = await buildPdf({
file: input,
pageType: values.pageType,
orientation: values.orientation,
scale: values.scale
});
setResult(pdfFile);
setImageSize(imageSize);
const compute = async (
optionsValues: InitialValuesType,
input: MultiImageInput[]
) => {
if (!input.length) return;
setIsProcessing(true);
try {
const files = input.sort((a, b) => a.order - b.order).map((i) => i.file);
const { pdfFile, imageSizes } = await buildPdf(files, optionsValues);
setResult(pdfFile);
setImageSizes(imageSizes);
} catch (error) {
throw new Error('Error converting image(s) to PDF:' + error);
} finally {
setIsProcessing(false);
}
};
return (
<ToolContent<FormValues, File | null>
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialFormValues}
initialValues={initialValues}
compute={compute}
inputComponent={
<Box>
<ToolImageInput
<ToolMultipleImageInput
type="image"
value={input}
onChange={setInput}
accept={[
'image/png',
'image/jpeg',
'image/webp',
'image/tiff',
'image/gif',
'image/heic',
'image/heif',
'image/x-adobe-dng',
'image/x-canon-cr2',
'image/x-nikon-nef',
'image/x-sony-arw',
'image/vnd.adobe.photoshop'
'image/heif'
]}
title="Input Image"
title={t('convertToPdf.inputTitle')}
/>
</Box>
}
@@ -76,7 +84,9 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
component: (
<Stack spacing={4}>
<Box>
<Typography variant="h6">PDF Type</Typography>
<Typography variant="h6">
{t('convertToPdf.options.type')}
</Typography>
<RadioGroup
row
value={values.pageType}
@@ -87,27 +97,37 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
<FormControlLabel
value="full"
control={<Radio />}
label="Full Size (Same as Image)"
label={t('convertToPdf.options.fullsize')}
/>
<FormControlLabel
value="a4"
control={<Radio />}
label="A4 Page"
label={t('convertToPdf.options.a4')}
/>
</RadioGroup>
{values.pageType === 'full' && imageSize && (
<Typography variant="body2" color="text.secondary">
Image size: {imageSize.widthMm.toFixed(1)} ×{' '}
{imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '}
{imageSize.heightPx} px)
</Typography>
{values.pageType === 'full' && imageSizes && (
<Stack spacing={1} mt={1}>
{imageSizes.map((size, index) => (
<Typography
key={index}
variant="body2"
color="text.secondary"
>
{t('convertToPdf.options.image')} {index + 1}:{' '}
{size.widthMm.toFixed(1)} × {size.heightMm.toFixed(1)}{' '}
mm ({size.widthPx} × {size.heightPx} px)
</Typography>
))}
</Stack>
)}
</Box>
{values.pageType === 'a4' && (
<Box>
<Typography variant="h6">Orientation</Typography>
<Typography variant="h6">
{t('convertToPdf.options.orientation')}
</Typography>
<RadioGroup
row
value={values.orientation}
@@ -121,12 +141,12 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
<FormControlLabel
value="portrait"
control={<Radio />}
label="Portrait (Vertical)"
label={t('convertToPdf.options.portrait')}
/>
<FormControlLabel
value="landscape"
control={<Radio />}
label="Landscape (Horizontal)"
label={t('convertToPdf.options.landscape')}
/>
</RadioGroup>
</Box>
@@ -134,8 +154,10 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
{values.pageType === 'a4' && (
<Box>
<Typography variant="h6">Scale</Typography>
<Typography>Scale image: {values.scale}%</Typography>
<Typography variant="h6">
{t('convertToPdf.options.scale')}
</Typography>
<Typography>{values.scale}%</Typography>
<Slider
value={values.scale}
onChange={(_, v) => updateField('scale', v as number)}
@@ -152,7 +174,12 @@ export default function ConvertToPdf({ title }: ToolComponentProps) {
] as const;
}}
resultComponent={
<ToolFileResult title="Output PDF" value={result} extension="pdf" />
<ToolFileResult
title={t('convertToPdf.resultTitle')}
value={result}
extension="pdf"
loading={isProcessing}
/>
}
/>
);
+2 -6
View File
@@ -19,14 +19,10 @@ export const tool = defineTool('pdf', {
'jpeg',
'png',
'gif',
'tiff',
'webp',
'heic',
'raw',
'psd',
'svg',
'quality',
'compression'
'heif',
'quality'
],
component: lazy(() => import('./index'))
});
+127 -45
View File
@@ -1,40 +1,98 @@
import jsPDF from 'jspdf';
import { Orientation, PageType, ImageSize } from './types';
import { ImageSize, InitialValuesType, LoadedImage } from './types';
import { heicTo, isHeic } from 'heic-to';
export interface ComputeOptions {
file: File;
pageType: PageType;
orientation: Orientation;
scale: number; // 10..100 (only applied for A4)
const pxToMm = (px: number) => px * 0.264583;
function returnImageFormat(file: File): 'PNG' | 'JPEG' {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
if (type.includes('png') || name.endsWith('.png')) return 'PNG';
if (
type.includes('jpeg') ||
type.includes('jpg') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg')
) {
return 'JPEG';
}
if (type.includes('webp') || name.endsWith('.webp')) return 'JPEG';
if (type.includes('gif') || name.endsWith('.gif')) return 'JPEG';
console.warn(`Unsupported format: ${file.type}, defaulting to JPEG`);
return 'JPEG';
}
export interface ComputeResult {
pdfFile: File;
imageSize: ImageSize;
async function loadImage(file: File): Promise<LoadedImage> {
let processedFile = file;
if (await isHeic(file)) {
const convertedBlob = await heicTo({
blob: file,
type: 'image/jpeg'
});
processedFile = new File([convertedBlob], file.name + '.jpg', {
type: 'image/jpeg'
});
}
const image = new Image();
const objectUrl = URL.createObjectURL(processedFile);
image.src = objectUrl;
await image.decode();
return {
image,
objectUrl,
format: returnImageFormat(file),
filename: processedFile.name
};
}
export async function buildPdf({
file,
pageType,
orientation,
scale
}: ComputeOptions): Promise<ComputeResult> {
const img = new Image();
img.src = URL.createObjectURL(file);
export async function buildPdf(
files: File[],
options: InitialValuesType
): Promise<{ pdfFile: File; imageSizes: ImageSize[] }> {
if (!files.length) {
throw new Error('No files selected');
}
const { pageType, orientation, scale } = options;
let loadedImages: LoadedImage[] = [];
const imageSizes: ImageSize[] = [];
const results = await Promise.all(
files.map((file) =>
loadImage(file).catch((err) => {
console.warn(`Skipping ${file.name}:`, err);
return null;
})
)
);
loadedImages = results.filter((img): img is LoadedImage => img !== null);
if (!loadedImages.length) {
throw new Error('No images could be loaded');
}
const { image: firstImage, filename: firstImageFileName } = loadedImages[0];
const firstImageWidthMm = pxToMm(firstImage.width);
const firstImageHeightMm = pxToMm(firstImage.height);
try {
await img.decode();
const pxToMm = (px: number) => px * 0.264583;
const imgWidthMm = pxToMm(img.width);
const imgHeightMm = pxToMm(img.height);
const pdf =
pageType === 'full'
? new jsPDF({
orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait',
orientation:
firstImageWidthMm > firstImageHeightMm ? 'landscape' : 'portrait',
unit: 'mm',
format: [imgWidthMm, imgHeightMm]
format: [firstImageWidthMm, firstImageHeightMm]
})
: new jsPDF({
orientation,
@@ -44,37 +102,61 @@ export async function buildPdf({
pdf.setDisplayMode('fullwidth');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
for (let index = 0; index < loadedImages.length; index += 1) {
const { image, format } = loadedImages[index];
const imageWidthMm = pxToMm(image.width);
const imageHeightMm = pxToMm(image.height);
const widthRatio = pageWidth / img.width;
const heightRatio = pageHeight / img.height;
const fitScale = Math.min(widthRatio, heightRatio);
if (index > 0) {
if (pageType === 'full') {
pdf.addPage(
[imageWidthMm, imageHeightMm],
imageWidthMm > imageHeightMm ? 'landscape' : 'portrait'
);
} else {
pdf.addPage('a4', orientation);
}
}
const finalWidth =
pageType === 'full' ? pageWidth : img.width * fitScale * (scale / 100);
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const finalHeight =
pageType === 'full' ? pageHeight : img.height * fitScale * (scale / 100);
const widthRatio = pageWidth / image.width;
const heightRatio = pageHeight / image.height;
const fitScale = Math.min(widthRatio, heightRatio);
const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2;
const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2;
const finalWidth =
pageType === 'full'
? pageWidth
: image.width * fitScale * (scale / 100);
pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight);
const finalHeight =
pageType === 'full'
? pageHeight
: image.height * fitScale * (scale / 100);
const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2;
const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2;
pdf.addImage(image, format, x, y, finalWidth, finalHeight);
imageSizes.push({
widthMm: imageWidthMm,
heightMm: imageHeightMm,
widthPx: image.width,
heightPx: image.height
});
}
const blob = pdf.output('blob');
const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf';
const firstFileName = firstImageFileName.replace(/\.[^/.]+$/, '');
const fileName =
files.length === 1 ? `${firstFileName}.pdf` : 'merged-images.pdf';
return {
pdfFile: new File([blob], fileName, { type: 'application/pdf' }),
imageSize: {
widthMm: imgWidthMm,
heightMm: imgHeightMm,
widthPx: img.width,
heightPx: img.height
}
imageSizes: imageSizes
};
} finally {
URL.revokeObjectURL(img.src);
loadedImages.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl));
}
}
+7 -6
View File
@@ -8,14 +8,15 @@ export interface ImageSize {
heightPx: number;
}
export interface FormValues {
export interface InitialValuesType {
pageType: PageType;
orientation: Orientation;
scale: number;
}
export const initialValues: FormValues = {
pageType: 'full',
orientation: 'portrait',
scale: 100
};
export interface LoadedImage {
image: HTMLImageElement;
objectUrl: string;
format: 'PNG' | 'JPEG';
filename: string;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { lazy } from 'react';
export const meta = defineTool('pdf', {
icon: 'material-symbols-light:merge',
component: lazy(() => import('./index')),
keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'],
keywords: ['pages', 'combine', 'document', 'join', 'append'],
path: 'merge-pdf',
i18n: {
name: 'pdf:mergePdf.title',
-2
View File
@@ -6,8 +6,6 @@ export const tool = defineTool('pdf', {
icon: 'material-symbols:lock',
keywords: [
'pdf',
'protect',
'password',
'secure',
'encrypt',
+8 -2
View File
@@ -92,7 +92,10 @@ export default function RotatePdf({
if (applyToAllPages) {
setPageRangePreview(
totalPages > 0
? t('rotatePdf.allPagesWillBeRotated', { count: totalPages })
? t('rotatePdf.allPagesWillBeRotated', {
count: totalPages,
plural: totalPages === 1 ? '' : 's'
})
: ''
);
return;
@@ -191,7 +194,10 @@ export default function RotatePdf({
<Box sx={{ mt: 2 }}>
{totalPages > 0 && (
<Typography variant="body2" sx={{ mb: 1 }}>
{t('rotatePdf.pdfPageCount', { count: totalPages })}
{t('rotatePdf.pdfPageCount', {
count: totalPages,
plural: totalPages === 1 ? '' : 's'
})}
</Typography>
)}
<TextFieldWithDesc
+9 -1
View File
@@ -13,6 +13,14 @@ export const tool = defineTool('pdf', {
path: 'rotate-pdf',
icon: 'carbon:rotate',
keywords: ['pdf', 'rotate', 'rotation', 'document', 'pages', 'orientation'],
keywords: [
'rotation',
'document',
'pages',
'orientation',
'oritent',
'spin',
'flip'
],
component: lazy(() => import('./index'))
});
+10 -2
View File
@@ -85,7 +85,12 @@ export default function SplitPdf({ title }: ToolComponentProps) {
}
try {
const count = parsePageRanges(pageRanges, totalPages).length;
setPageRangePreview(t('splitPdf.pageExtractionPreview', { count }));
setPageRangePreview(
t('splitPdf.pageExtractionPreview', {
count,
plural: count === 1 ? '' : 's'
})
);
} catch (error) {
setPageRangePreview('');
}
@@ -137,7 +142,10 @@ export default function SplitPdf({ title }: ToolComponentProps) {
<Box>
{totalPages > 0 && (
<Typography variant="body2" sx={{ mb: 1 }}>
{t('splitPdf.pdfPageCount', { count: totalPages })}
{t('splitPdf.pdfPageCount', {
count: totalPages,
plural: totalPages === 1 ? '' : 's'
})}
</Typography>
)}
<TextFieldWithDesc
+1 -1
View File
@@ -4,7 +4,7 @@ import { lazy } from 'react';
export const meta = defineTool('pdf', {
icon: 'material-symbols-light:call-split-rounded',
component: lazy(() => import('./index')),
keywords: ['pdf', 'split', 'extract', 'pages', 'range', 'document'],
keywords: ['extract', 'pages', 'range', 'document', 'remove'],
path: 'split-pdf',
i18n: {
name: 'pdf:splitPdf.title',
+1 -1
View File
@@ -5,7 +5,7 @@ export const tool = defineTool('string', {
path: 'base64',
icon: 'tabler:number-64-small',
keywords: ['base64'],
keywords: ['b64'],
component: lazy(() => import('./index')),
i18n: {
name: 'string:base64.title',
@@ -11,15 +11,6 @@ export const tool = defineTool('string', {
},
path: 'hidden-character-detector',
icon: 'material-symbols:visibility-off',
keywords: [
'hidden',
'character',
'detector',
'unicode',
'rtl',
'override',
'security',
'invisible'
],
keywords: ['unicode', 'rtl', 'override', 'security', 'invisible'],
component: lazy(() => import('./index'))
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { lazy } from 'react';
export const tool = defineTool('string', {
path: 'to-morse',
icon: 'arcticons:morse',
keywords: ['to', 'morse'],
keywords: [],
component: lazy(() => import('./index')),
i18n: {
name: 'string:toMorse.title',
+1 -10
View File
@@ -5,16 +5,7 @@ export const tool = defineTool('string', {
path: 'url-decode-string',
icon: 'codicon:symbol-string',
keywords: [
'url',
'decode',
'string',
'url decode',
'unescape',
'encoding',
'percent',
'decode url'
],
keywords: ['string', 'unescape', 'encoding', 'percent', 'decoding'],
component: lazy(() => import('./index')),
i18n: {
name: 'string:urlDecode.toolInfo.title',
+1 -1
View File
@@ -5,7 +5,7 @@ export const tool = defineTool('string', {
path: 'url-encode-string',
icon: 'ic:baseline-percentage',
keywords: ['url', 'encode', 'string', 'url encode', 'encoding', 'percent'],
keywords: ['string', 'encoding', 'percent', 'escape'],
component: lazy(() => import('./index')),
i18n: {
name: 'string:urlEncode.toolInfo.title',
@@ -0,0 +1,156 @@
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 { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import { DiscordTimestampGenerator } from './service';
import { useTranslation } from 'react-i18next';
import { DiscordTimestampFormat, InitialValuesType } from './types';
const initialValues: InitialValuesType = {
format: 'F',
enforceUTC: true
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Convert a single datetime',
description:
"In this example, we convert a single datetime into a Discord timestamp using the Long Date & Time format (F). The input is an ISO 8601 datetime string and the output is a Discord timestamp that will display the full date and time in each viewer's local timezone.",
sampleText: `2025-03-15T10:00:00`,
sampleResult: `<t:1742032800:F>`,
sampleOptions: {
...initialValues,
format: 'F'
}
},
{
title: 'Convert multiple datetimes',
description:
'In this example, we convert multiple datetimes at once — one per line. Blank lines are preserved in the output. Each valid datetime is converted to a Discord timestamp using the Short Date & Time format (f).',
sampleText: `2025-03-15T10:00:00
2025-07-20T18:45:00
2025-11-05T08:15:00`,
sampleResult: `<t:1742032800:f>
<t:1753037100:f>
<t:1762330500:f>`,
sampleOptions: {
...initialValues,
format: 'f'
}
},
{
title: 'Relative timestamps',
description:
'In this example, we use the Relative format (R) which displays how long ago or how far in the future a datetime is — for example "2 hours ago" or "in 3 days". This format is useful for countdowns or activity feeds and updates dynamically in Discord.',
sampleText: `2025-03-15T10:00:00
2025-07-20T18:45:00
2025-11-05T08:15:00`,
sampleResult: `<t:1742032800:R>
<t:1753037100:R>
<t:1762330500:R>`,
sampleOptions: {
...initialValues,
format: 'R'
}
}
];
type formatProps = {
label: string;
value: DiscordTimestampFormat;
};
export default function TruncateClockTime({
title,
longDescription
}: ToolComponentProps) {
const { t } = useTranslation('time');
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (optionsValues: InitialValuesType, input: string) => {
setResult(DiscordTimestampGenerator(input, optionsValues));
};
const FORMATS: formatProps[] = [
{ label: t('discordTimestamp.formats.short_time'), value: 't' },
{ label: t('discordTimestamp.formats.long_time'), value: 'T' },
{ label: t('discordTimestamp.formats.short_date'), value: 'd' },
{ label: t('discordTimestamp.formats.long_date'), value: 'D' },
{ label: t('discordTimestamp.formats.short_datetime'), value: 'f' },
{ label: t('discordTimestamp.formats.long_datetime'), value: 'F' },
{ label: t('discordTimestamp.formats.relative'), value: 'R' }
];
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: t('discordTimestamp.utc.title'),
component: (
<Box>
<CheckboxWithDesc
checked={values.enforceUTC}
onChange={(value) => updateField('enforceUTC', value)}
title={t('discordTimestamp.utc.label')}
description={t('discordTimestamp.utc.description')}
/>
</Box>
)
},
{
title: t('discordTimestamp.formats.title'),
component: (
<Box>
<SelectWithDesc
selected={values.format}
onChange={(value) => updateField('format', value)}
options={FORMATS.map((format) => ({
label: format.label,
value: format.value
}))}
description={t('discordTimestamp.formats.description')}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolTextInput
value={input}
title={t('discordTimestamp.inputTitle')}
onChange={setInput}
/>
}
resultComponent={
<ToolTextResult
title={t('discordTimestamp.outputTitle')}
value={result}
/>
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{
title: title,
description: longDescription
}}
exampleCards={exampleCards}
/>
);
}
@@ -0,0 +1,17 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
const DiscordTimestamp = lazy(() => import('./index'));
export const tool = defineTool('time', {
path: 'discord-timestamp',
component: DiscordTimestamp,
icon: 'ic:baseline-discord',
keywords: ['discord', 'timestamp', 'time', 'datetime'],
i18n: {
name: 'time:discordTimestamp.name',
description: 'time:discordTimestamp.description',
shortDescription: 'time:discordTimestamp.shortDescription',
longDescription: 'time:discordTimestamp.toolInfo.description',
userTypes: ['generalUsers', 'developers']
}
});
@@ -0,0 +1,61 @@
import { DiscordTimestampFormat, InitialValuesType } from './types';
/**
* Converts a Date object into a Discord timestamp string.
*
* @param date JavaScript Date object to convert.
* @param format Discord timestamp format.
* Allowed values: 't', 'T', 'd', 'D', 'f', 'F', 'R'.
* Defaults to 'F' (full date and time).
*
* @returns Discord-formatted timestamp string.
*/
export function compute(
date: Date,
format: DiscordTimestampFormat = 'F'
): string {
const unix = Math.floor(date.getTime() / 1000);
return `<t:${unix}:${format}>`;
}
/**
* Converts a multiline string of datetime values into Discord timestamp strings.
*
* Processes each line independently:
* - Blank lines are preserved as-is in the output.
* - Valid datetime strings are converted to Discord timestamp format.
* - Invalid or unparseable lines show error message.
*
* @param input Multiline string where each line is an UTC datetime value.
* @param options - `format`: Discord timestamp format ('t', 'T', 'd', 'D', 'f', 'F', 'R').
* - `enforceUTC`: if true, input is treated as UTC; otherwise as local time.
*
* @returns Multiline string with each valid datetime replaced by a Discord timestamp.
*/
export function DiscordTimestampGenerator(
input: string,
options: InitialValuesType
): string {
if (!input) return '';
const { format, enforceUTC } = options;
const results: string[] = [];
input.split('\n').forEach((datetime) => {
if (!datetime.trim()) {
results.push(''); // Blank lines stays
return;
}
const raw = datetime.trim();
const date = enforceUTC ? new Date(raw + 'Z') : new Date(raw);
if (!isNaN(date.getTime())) {
results.push(compute(date, format));
} else {
results.push(`❌ ${datetime}`); // keep original if unparseable
}
});
return results.join('\n');
}
@@ -0,0 +1,6 @@
export type DiscordTimestampFormat = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
export type InitialValuesType = {
format: DiscordTimestampFormat;
enforceUTC: boolean;
};
+3 -1
View File
@@ -8,6 +8,7 @@ import { tool as convertSecondsToTime } from './convert-seconds-to-time/meta';
import { tool as convertTimetoSeconds } from './convert-time-to-seconds/meta';
import { tool as truncateClockTime } from './truncate-clock-time/meta';
import { tool as checkLeapYear } from './check-leap-years/meta';
import { tool as discordTimestamp } from './discord-timestamp/meta';
export const timeTools = [
daysDoHours,
@@ -19,5 +20,6 @@ export const timeTools = [
timeCrontabGuru,
checkLeapYear,
timeConvertUnixToDate,
timeConvertTimeToDecimal
timeConvertTimeToDecimal,
discordTimestamp
];
+251
View File
@@ -0,0 +1,251 @@
import { Box, Typography, Slider } from '@mui/material';
import React, { useEffect, useState, useCallback } from 'react';
import * as Yup from 'yup';
import ToolFileResult from '@components/result/ToolFileResult';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolVideoInput from '@components/input/ToolVideoInput';
import ToolAudioInput from '@components/input/ToolAudioInput';
import SimpleRadio from '@components/options/SimpleRadio';
import SelectWithDesc from '@components/options/SelectWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { useTranslation } from 'react-i18next';
import { addAudioToVideo } from './service';
import { AudioMode, timingMode, initialValuesType } from './types';
import debounce from 'lodash/debounce';
const initialValues: initialValuesType = {
mode: 'replace',
volume: 100,
loop: true,
startTime: '00:00:00',
endTime: '00:00:00',
timingMode: 'default'
};
const validationSchema = Yup.object({
mode: Yup.string().oneOf(['replace', 'mix']).required(),
volume: Yup.number().min(0).max(200).required(),
loop: Yup.boolean().required(),
timingMode: Yup.string()
.oneOf(['default', 'start', 'end', 'startEnd'])
.required(),
startTime: Yup.string()
.matches(/^\d{2}:\d{2}:\d{2}$/)
.required(),
endTime: Yup.string()
.matches(/^\d{2}:\d{2}:\d{2}$/)
.required()
});
const modeOptions: { value: AudioMode; label: string }[] = [
{ value: 'replace', label: 'replace' },
{ value: 'mix', label: 'mix' }
];
const durationModeOptions = [
{ value: 'default', label: 'addAudio.timingDefault' },
{ value: 'start', label: 'addAudio.timingStart' },
{ value: 'end', label: 'addAudio.timingEnd' },
{ value: 'startEnd', label: 'addAudio.timingStartEnd' }
] as const;
export default function AddAudio({ title }: ToolComponentProps) {
const { t } = useTranslation('video');
const [videoInput, setVideoInput] = useState<File | null>(null);
const [audioInput, setAudioInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [currentValues, setCurrentValues] = useState(initialValues);
// core compute function
const compute = async (
video: File,
audio: File,
options: initialValuesType
) => {
if (!video || !audio) return;
try {
setLoading(true);
const outputFile = await addAudioToVideo(video, audio, options);
setResult(outputFile);
} catch (err) {
console.error('Error adding audio to video:', err);
} finally {
setLoading(false);
}
};
// debounced version for fast option changes
const debouncedCompute = useCallback(debounce(compute, 1000), []);
// automatically recompute whenever video, audio, or options change
useEffect(() => {
if (videoInput && audioInput) {
debouncedCompute(videoInput, audioInput, currentValues);
}
}, [videoInput, audioInput, currentValues]);
const getGroups: GetGroupsType<typeof initialValues> = ({
values,
updateField
}) => [
{
title: t('addAudio.audioFile'),
component: (
<ToolAudioInput
value={audioInput}
onChange={(file) => setAudioInput(file)}
title={t('addAudio.audioInputTitle')}
/>
)
},
{
title: t('addAudio.mode'),
component: (
<Box>
{modeOptions.map((option) => (
<SimpleRadio
key={option.value}
title={
option.value === 'replace'
? t('addAudio.modeReplace')
: t('addAudio.modeMix')
}
checked={values.mode === option.value}
onClick={() => {
updateField('mode', option.value);
setCurrentValues((prev) => ({ ...prev, mode: option.value }));
}}
/>
))}
<Typography mt={3} mb={1} fontSize={22}>
{t('addAudio.loopOption')}
</Typography>
<SimpleRadio
title={t('addAudio.loop')}
checked={values.loop === true}
onClick={() => {
updateField('loop', true);
setCurrentValues((prev) => ({ ...prev, loop: true }));
}}
/>
<SimpleRadio
title={t('addAudio.noLoop')}
checked={values.loop === false}
onClick={() => {
updateField('loop', false);
setCurrentValues((prev) => ({ ...prev, loop: false }));
}}
/>
</Box>
)
},
{
title: t('addAudio.volume'),
component: (
<Box>
<Slider
value={values.volume}
onChange={(_, value) => {
const vol = Array.isArray(value) ? value[0] : value;
updateField('volume', vol);
setCurrentValues((prev) => ({ ...prev, volume: vol }));
}}
min={0}
max={200}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
sx={{ mt: 1 }}
/>
<Typography mt={0.5} fontSize={14}>
{values.volume}%
</Typography>
<Typography mt={2} mb={1} fontSize={22}>
{t('addAudio.timing')}
</Typography>
<SelectWithDesc
selected={values.timingMode}
options={durationModeOptions.map((opt) => ({
label: t(opt.label),
value: opt.value
}))}
onChange={(value) => {
updateField('timingMode', value);
setCurrentValues((prev) => ({ ...prev, timingMode: value }));
if (value === 'default') {
updateField('startTime', '00:00:00');
updateField('endTime', '00:00:00');
setCurrentValues((prev) => ({
...prev,
startTime: '00:00:00',
endTime: '00:00:00'
}));
}
}}
description={t('addAudio.timingModeDesc')}
/>
{(values.timingMode === 'start' ||
values.timingMode === 'startEnd') && (
<Box mt={1}>
<TextFieldWithDesc
value={values.startTime}
onOwnChange={(val) => {
updateField('startTime', val);
setCurrentValues((prev) => ({ ...prev, startTime: val }));
}}
label={t('addAudio.startTime')}
description={t('addAudio.startTimeDesc')}
/>
</Box>
)}
{(values.timingMode === 'end' ||
values.timingMode === 'startEnd') && (
<Box mt={1}>
<TextFieldWithDesc
value={values.endTime}
onOwnChange={(val) => {
updateField('endTime', val);
setCurrentValues((prev) => ({ ...prev, endTime: val }));
}}
label={t('addAudio.endTime')}
description={t('addAudio.endTimeDesc')}
/>
</Box>
)}
</Box>
)
}
];
return (
<ToolContent
title={title}
input={videoInput}
inputComponent={
<ToolVideoInput
value={videoInput}
onChange={setVideoInput}
title={t('addAudio.videoInputTitle')}
/>
}
resultComponent={
<ToolFileResult
title={t('addAudio.resultTitle')}
value={result}
extension="mp4"
loading={loading}
loadingText={t('addAudio.loadingText')}
/>
}
initialValues={initialValues}
getGroups={getGroups}
compute={() => {}} // compute is now handled by useEffect
setInput={setVideoInput}
validationSchema={validationSchema}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
path: 'add-audio',
icon: 'tabler:music-plus',
keywords: [
'add',
'audio',
'video',
'music',
'sound',
'track',
'replace',
'mix',
'mp4',
'mp3',
'background music'
],
component: lazy(() => import('./index')),
i18n: {
name: 'video:addAudio.title',
description: 'video:addAudio.description',
shortDescription: 'video:addAudio.shortDescription',
userTypes: ['generalUsers']
}
});
+135
View File
@@ -0,0 +1,135 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { initialValuesType } from './types';
const ffmpeg = new FFmpeg();
async function ensureLoaded() {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
}
export function timeToSeconds(time: string): number {
const parts = time.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return parts[0] || 0;
}
function hasDurationConstraint(options: initialValuesType): boolean {
const start = timeToSeconds(options.startTime);
const end = timeToSeconds(options.endTime);
return start > 0 || end > 0;
}
function buildAudioFilterChain(options: initialValuesType): string {
const volumeScale = (options.volume / 100).toFixed(2);
const start = timeToSeconds(options.startTime);
const end = timeToSeconds(options.endTime);
const hasConstraint = start > 0 || end > 0;
const delay = start * 1000;
const filters: string[] = [];
filters.push(`volume=${volumeScale}`);
if (hasConstraint) {
if (start > 0 && end > 0) {
filters.push(`atrim=start=0:end=${end - start}`);
filters.push(`adelay=${delay}|${delay}`);
} else if (start > 0) {
filters.push(`adelay=${delay}|${delay}`);
} else if (end > 0) {
filters.push(`atrim=start=0:end=${end}`);
}
filters.push('apad');
}
return filters.join(',');
}
function buildAudioModeFilter(options: initialValuesType): string {
const chain = buildAudioFilterChain(options);
return options.mode === 'replace'
? `[1:a]${chain}[aout]`
: `[1:a]${chain}[a1];[0:a][a1]amix=inputs=2:duration=longest[aout]`;
}
export async function addAudioToVideo(
video: File,
audio: File,
options: initialValuesType
): Promise<File> {
await ensureLoaded();
const inputVideo = 'input.mp4';
const inputAudio = 'input-audio.mp3';
const outputName = 'output.mp4';
await ffmpeg.writeFile(inputVideo, await fetchFile(video));
await ffmpeg.writeFile(inputAudio, await fetchFile(audio));
const audioModeFilter = buildAudioModeFilter(options);
const inputArgs: string[] = [];
inputArgs.push('-i', inputVideo);
if (options.loop) {
inputArgs.push('-stream_loop', '-1');
}
inputArgs.push('-i', inputAudio);
const hasConstraint = hasDurationConstraint(options);
let args: string[];
if (options.mode === 'replace') {
args = [
...inputArgs,
'-c:v',
'copy',
'-filter_complex',
audioModeFilter,
'-map',
'0:v:0',
'-map',
'[aout]',
'-shortest',
'-y',
outputName
];
} else {
args = [
...inputArgs,
'-c:v',
'copy',
'-filter_complex',
audioModeFilter,
'-map',
'0:v',
'-map',
'[aout]',
...(hasConstraint ? [] : ['-shortest']),
'-y',
outputName
];
}
try {
await ffmpeg.exec(args);
const data = await ffmpeg.readFile(outputName);
return new File(
[new Blob([data as any], { type: 'video/mp4' })],
`${video.name.replace(/\.[^/.]+$/, '')}_with_audio.mp4`,
{ type: 'video/mp4' }
);
} catch (error) {
console.error('FFmpeg execution failed:', error);
throw error;
} finally {
await ffmpeg.deleteFile(outputName);
}
}
+12
View File
@@ -0,0 +1,12 @@
export type AudioMode = 'replace' | 'mix';
export type timingMode = 'default' | 'start' | 'end' | 'startEnd';
export interface initialValuesType {
mode: AudioMode;
volume: number;
loop: boolean;
startTime: string;
endTime: string;
timingMode: timingMode;
}
+3 -1
View File
@@ -8,6 +8,7 @@ import { tool as rotateVideo } from './rotate/meta';
import { tool as compressVideo } from './compress/meta';
import { tool as loopVideo } from './loop/meta';
import { tool as cropVideo } from './crop-video/meta';
import { tool as addAudio } from './add-audio/meta';
export const videoTools = [
...gifTools,
@@ -19,5 +20,6 @@ export const videoTools = [
cropVideo,
changeSpeed,
videoToGif,
videoMergeVideo
videoMergeVideo,
addAudio
];
+129
View File
@@ -0,0 +1,129 @@
import { describe, it, expect } from 'vitest';
import type { TFunction } from 'i18next';
import { filterTools } from './index';
import type { DefinedTool } from './defineTool';
import type { FullI18nKey, I18nNamespaces } from '../i18n';
const mergePdfTool = {
type: 'pdf',
path: 'pdf/merge-pdf',
name: 'pdf:mergePdf.title' as FullI18nKey,
description: 'pdf:mergePdf.description' as FullI18nKey,
shortDescription: 'pdf:mergePdf.shortDescription' as FullI18nKey,
icon: 'icon',
keywords: [
'pdf',
'merge',
'extract',
'pages',
'combine',
'document',
'join',
'append'
],
component: (() => null) as unknown,
userTypes: ['generalUsers']
} as unknown as DefinedTool;
const base64Tool = {
type: 'string',
path: 'string/base64',
name: 'string:base64.title' as FullI18nKey,
description: 'string:base64.description' as FullI18nKey,
shortDescription: 'string:base64.shortDescription' as FullI18nKey,
icon: 'icon',
keywords: ['b64'],
component: (() => null) as unknown,
userTypes: ['generalUsers', 'developers']
} as unknown as DefinedTool;
const otherPdfTool = {
type: 'pdf',
path: 'pdf/other-pdf',
name: 'pdf:otherPdf.title' as FullI18nKey,
description: 'pdf:otherPdf.description' as FullI18nKey,
shortDescription: 'pdf:otherPdf.shortDescription' as FullI18nKey,
icon: 'icon',
keywords: [],
component: (() => null) as unknown,
userTypes: ['generalUsers']
} as unknown as DefinedTool;
type NamespacedT = TFunction<I18nNamespaces[]>;
const enTranslations: Record<string, string> = {
'pdf:mergePdf.title': 'Merge PDF',
'pdf:mergePdf.description': 'Merge multiple PDF files into one document',
'pdf:mergePdf.shortDescription': 'Merge PDF files',
'pdf:otherPdf.title': 'Document helper',
'pdf:otherPdf.description': 'Helper for pdf tasks',
'pdf:otherPdf.shortDescription': 'PDF helper',
'string:base64.title': 'Base64 Encoder/Decoder',
'string:base64.description': 'Encode or decode Base64 text',
'string:base64.shortDescription': 'Convert text to and from Base64'
};
const esTranslations: Record<string, string> = {
'pdf:mergePdf.title': 'Unir PDF',
'pdf:mergePdf.description': 'Unir varios archivos PDF en un solo documento',
'pdf:mergePdf.shortDescription': 'Unir archivos PDF'
};
const makeT = (dict: Record<string, string>): NamespacedT =>
((key: string) => dict[key] ?? key) as unknown as NamespacedT;
describe('filterTools token-based search', () => {
const tools = [mergePdfTool, base64Tool, otherPdfTool];
const tEn = makeT(enTranslations);
const tEs = makeT(esTranslations);
it('returns all tools when query is empty or whitespace', () => {
expect(filterTools(tools, '', [], tEn)).toEqual(tools);
expect(filterTools(tools, ' ', [], tEn)).toEqual(tools);
});
it('matches tools regardless of word order and extra whitespace', () => {
const result1 = filterTools(tools, 'pdf merge', [], tEn);
const result2 = filterTools(tools, ' merge pdf ', [], tEn);
expect(result1).toContain(mergePdfTool);
expect(result2).toContain(mergePdfTool);
});
it('matches base64 tool with different queries and is case-insensitive', () => {
expect(filterTools(tools, 'Base64', [], tEn)).toContain(base64Tool);
expect(filterTools(tools, 'base64 encoder', [], tEn)).toContain(base64Tool);
});
it('matches tools using keyword-based English synonyms', () => {
const result = filterTools(tools, 'pdf join', [], tEn);
expect(result).toContain(mergePdfTool);
});
it('matches base64 tool with spaced and short-form variants', () => {
const resultBase64Spaced = filterTools(tools, 'base 64', [], tEn);
const resultB64 = filterTools(tools, 'b64', [], tEn);
expect(resultBase64Spaced).toContain(base64Tool);
expect(resultB64).toContain(base64Tool);
});
it('ignores trailing spaces', () => {
const result = filterTools(tools, 'pdf merge ', [], tEn);
expect(result).toContain(mergePdfTool);
});
it('works with non-English localized strings', () => {
const result = filterTools(tools, 'unir pdf', [], tEs);
expect(result).toContain(mergePdfTool);
});
it('tolerates single-character typos in query tokens', () => {
const result = filterTools(tools, 'merhe pdf', [], tEn);
expect(result[0]).toBe(mergePdfTool);
});
it('ranks tools with title matches above description-only matches', () => {
const result = filterTools(tools, 'pdf', [], tEn);
expect(result[0]).toBe(mergePdfTool);
});
});
+162 -24
View File
@@ -29,7 +29,6 @@ const toolCategoriesOrder: ToolCategory[] = [
'csv',
'number',
'png',
'time',
'xml',
'gif',
'converters'
@@ -90,12 +89,6 @@ const categoriesConfig: {
value: 'translation:categories.json.description',
title: 'translation:categories.json.title'
},
{
type: 'time',
icon: 'mdi:clock-time-five',
value: 'translation:categories.time.description',
title: 'translation:categories.time.title'
},
{
type: 'csv',
icon: 'material-symbols-light:csv-outline',
@@ -176,11 +169,132 @@ export const filterToolsByUserTypes = (
});
};
// use for changelogs
// console.log(
// 'tools',
// tools.map(({ name, type }) => ({ type, name }))
// );
/**
* Returns the Levenshtein distance between two strings.
* @param a - First string.
* @param b - Second string.
* @returns Minimum number of single-character edits (insert, delete, substitute).
*/
const levenshtein = (a: string, b: string): number => {
if (a === b) return 0;
const aLen = a.length;
const bLen = b.length;
if (aLen === 0) return bLen;
if (bLen === 0) return aLen;
const dp: number[][] = Array.from({ length: aLen + 1 }, () =>
Array<number>(bLen + 1).fill(0)
);
for (let i = 0; i <= aLen; i += 1) dp[i][0] = i;
for (let j = 0; j <= bLen; j += 1) dp[0][j] = j;
for (let i = 1; i <= aLen; i += 1) {
for (let j = 1; j <= bLen; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[aLen][bLen];
};
type SearchField = {
text: string;
words: string[];
weight: number;
};
const splitWords = (text: string): string[] =>
text.split(/[^a-z0-9]+/g).filter(Boolean);
/**
* Normalizes a string by converting it to lowercase and removing diacritics
* (accent marks). Useful for locale-insensitive string comparison and search.
*
* @param text - The string to normalize
* @returns The normalized string with lowercase letters and no diacritics
*/
const normalizeText = (text: string): string =>
text
.toLowerCase()
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.trim()
.replace(/\s+/g, ' ');
const computeToolScore = (
tool: DefinedTool,
tokens: string[],
t: TFunction<I18nNamespaces[]>
): number => {
const name = normalizeText(t(tool.name));
const shortDescription = normalizeText(t(tool.shortDescription));
const description = normalizeText(t(tool.description));
const fields: SearchField[] = [
{ text: name, words: splitWords(name), weight: 5 },
{ text: shortDescription, words: splitWords(shortDescription), weight: 3 },
{ text: description, words: splitWords(description), weight: 2 },
...(tool.keywords ?? []).map((kw) => {
const normalized = normalizeText(kw);
return {
text: normalized,
words: splitWords(normalized),
weight: 4
};
})
];
let totalScore = 0;
for (const token of tokens) {
if (!token) continue;
let bestForToken = 0;
for (const field of fields) {
let fieldScore = 0;
if (field.text.includes(token)) {
// Base score for substring match
fieldScore = field.weight * 2;
if (field.words.includes(token)) {
// Boost whole-word matches
fieldScore += field.weight;
}
} else {
for (const word of field.words) {
// Quick length check before full distance calculation
if (Math.abs(word.length - token.length) > 1) continue;
const dist = levenshtein(token, word);
if (dist === 1) {
fieldScore = Math.max(fieldScore, field.weight);
}
}
}
if (fieldScore > bestForToken) {
bestForToken = fieldScore;
}
}
// If any token fails to match (exact or fuzzy), treat the tool as a non-match.
if (bestForToken === 0) return 0;
totalScore += bestForToken;
}
return totalScore;
};
export const filterTools = (
tools: DefinedTool[],
query: string,
@@ -194,20 +308,44 @@ export const filterTools = (
filteredTools = filterToolsByUserTypes(tools, userTypes);
}
// Then filter by search query
if (!query) return filteredTools;
const normalizedQuery = normalizeText(query);
const lowerCaseQuery = query.toLowerCase();
// If query is empty after normalization, return all tools (after user-type filtering)
if (!normalizedQuery) return filteredTools;
return filteredTools.filter(
(tool) =>
t(tool.name).toLowerCase().includes(lowerCaseQuery) ||
t(tool.description).toLowerCase().includes(lowerCaseQuery) ||
t(tool.shortDescription).toLowerCase().includes(lowerCaseQuery) ||
tool.keywords.some((keyword) =>
keyword.toLowerCase().includes(lowerCaseQuery)
)
);
const rawTokens = normalizedQuery.split(' ').filter(Boolean);
if (rawTokens.length === 0) return filteredTools;
// Expand tokens with simple alpha+digit concatenation variants, e.g. "base" + "64" -> "base64".
// This, combined with per-tool `keywords`, allows us to support aliases like
// "base 64" / "base64" / "b64" without requiring every form in every keyword list.
const tokens: string[] = [...rawTokens];
for (let i = 0; i < rawTokens.length - 1; i += 1) {
const current = rawTokens[i];
const next = rawTokens[i + 1];
if (/^[a-zA-Z]+$/.test(current) && /^\d+$/.test(next)) {
tokens.push(`${current}${next}`);
}
}
const scored = filteredTools
.map((tool) => ({
tool,
score: computeToolScore(tool, tokens, t)
}))
.filter(({ score }) => score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aName = t(a.tool.name).toLowerCase();
const bName = t(b.tool.name).toLowerCase();
return aName.localeCompare(bName);
});
return scored.map(({ tool }) => tool);
};
export const getToolsByCategory = (
+18
View File
@@ -0,0 +1,18 @@
/**
* Collects all unique keys from an array of row objects, preserving first-encountered order.
* Handles sparse rows where different rows may have different keys.
*
* @param rows - Array of flattened row objects
* @returns Array of unique header strings in insertion order
*
* @example
* getJsonHeaders([{ a: '1' }, { a: '2', b: '3' }]) // → ['a', 'b']
*/
export function getJsonHeaders(rows: Record<string, string>[]): string[] {
return Array.from(
rows.reduce<Set<string>>((set, row) => {
Object.keys(row).forEach((key) => set.add(key));
return set;
}, new Set())
);
}