Merge branch 'main' into pr/bhavesh158/322
This commit is contained in:
Vendored
+2
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const CheckboxWithDesc = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={3}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -16,7 +16,7 @@ const TextFieldWithDesc = ({
|
||||
...props
|
||||
}: TextFieldProps & OwnProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={3}>
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
+2
-2
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,8 +6,6 @@ export const tool = defineTool('pdf', {
|
||||
icon: 'material-symbols:lock',
|
||||
|
||||
keywords: [
|
||||
'pdf',
|
||||
'protect',
|
||||
'password',
|
||||
'secure',
|
||||
'encrypt',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'))
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user