mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-23 16:09:30 +02:00
Merge branch 'main' into feat/pdf-merge
This commit is contained in:
@@ -12,7 +12,7 @@ import { Icon } from '@iconify/react';
|
||||
const exampleTools: { label: string; url: string }[] = [
|
||||
{
|
||||
label: 'Create a transparent image',
|
||||
url: '/png/create-transparent'
|
||||
url: '/image-generic/create-transparent'
|
||||
},
|
||||
{ label: 'Prettify JSON', url: '/json/prettify' },
|
||||
{ label: 'Change GIF speed', url: '/gif/change-speed' },
|
||||
@@ -35,7 +35,7 @@ export default function Hero() {
|
||||
newInputValue: string
|
||||
) => {
|
||||
setInputValue(newInputValue);
|
||||
setFilteredTools(_.shuffle(filterTools(tools, newInputValue)));
|
||||
setFilteredTools(filterTools(tools, newInputValue));
|
||||
};
|
||||
|
||||
return (
|
||||
|
@@ -48,7 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
|
||||
src="https://ghbtns.com/github-btn.html?user=iib0011&repo=omni-tools&type=star&count=true&size=large"
|
||||
frameBorder="0"
|
||||
scrolling="0"
|
||||
width="130"
|
||||
width="150"
|
||||
height="30"
|
||||
title="GitHub"
|
||||
></iframe>,
|
||||
|
@@ -40,7 +40,7 @@ const FormikListenerComponent = <T,>({
|
||||
|
||||
interface ToolContentProps<T, I> extends ToolComponentProps {
|
||||
inputComponent?: ReactNode;
|
||||
resultComponent: ReactNode;
|
||||
resultComponent?: ReactNode;
|
||||
renderCustomInput?: (
|
||||
values: T,
|
||||
setFieldValue: (fieldName: string, value: any) => void
|
||||
@@ -57,6 +57,7 @@ interface ToolContentProps<T, I> extends ToolComponentProps {
|
||||
setInput?: React.Dispatch<React.SetStateAction<I>>;
|
||||
validationSchema?: any;
|
||||
onValuesChange?: (values: T) => void;
|
||||
verticalGroups?: boolean;
|
||||
}
|
||||
|
||||
export default function ToolContent<T extends FormikValues, I>({
|
||||
@@ -72,7 +73,8 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
setInput,
|
||||
validationSchema,
|
||||
renderCustomInput,
|
||||
onValuesChange
|
||||
onValuesChange,
|
||||
verticalGroups
|
||||
}: ToolContentProps<T, I>) {
|
||||
return (
|
||||
<Box>
|
||||
@@ -97,7 +99,7 @@ export default function ToolContent<T extends FormikValues, I>({
|
||||
input={input}
|
||||
onValuesChange={onValuesChange}
|
||||
/>
|
||||
<ToolOptions getGroups={getGroups} />
|
||||
<ToolOptions getGroups={getGroups} vertical={verticalGroups} />
|
||||
|
||||
{toolInfo && toolInfo.title && toolInfo.description && (
|
||||
<ToolInfo
|
||||
|
@@ -5,6 +5,7 @@ import { capitalizeFirstLetter } from '../utils/string';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Icon, IconifyIcon } from '@iconify/react';
|
||||
import { categoriesColors } from '../config/uiConfig';
|
||||
import { getToolsByCategory } from '@tools/index';
|
||||
|
||||
const StyledButton = styled(Button)(({ theme }) => ({
|
||||
backgroundColor: 'white',
|
||||
@@ -70,7 +71,9 @@ export default function ToolHeader({
|
||||
items={[
|
||||
{ title: 'All tools', link: '/' },
|
||||
{
|
||||
title: capitalizeFirstLetter(type),
|
||||
title: getToolsByCategory().find(
|
||||
(category) => category.type === type
|
||||
)!.rawTitle,
|
||||
link: '/categories/' + type
|
||||
},
|
||||
{ title }
|
||||
|
@@ -6,18 +6,20 @@ export default function ToolInputAndResult({
|
||||
result
|
||||
}: {
|
||||
input?: ReactNode;
|
||||
result: ReactNode;
|
||||
result?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Grid id="tool" container spacing={2}>
|
||||
{input && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{input}
|
||||
if (input || result) {
|
||||
return (
|
||||
<Grid id="tool" container spacing={2}>
|
||||
{input && (
|
||||
<Grid item xs={12} md={6}>
|
||||
{input}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={input ? 6 : 12}>
|
||||
{result}
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12} md={input ? 6 : 12}>
|
||||
{result}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -53,7 +53,10 @@ export default function ToolLayout({
|
||||
{children}
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<AllTools
|
||||
title={`All ${capitalizeFirstLetter(type)} tools`}
|
||||
title={`All ${capitalizeFirstLetter(
|
||||
getToolsByCategory().find((category) => category.type === type)!
|
||||
.rawTitle
|
||||
)} tools`}
|
||||
toolCards={otherCategoryTools}
|
||||
/>
|
||||
</Box>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useContext, useEffect } from 'react';
|
||||
import React, { ReactNode, useContext, useEffect, useState } from 'react';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import InputHeader from '../InputHeader';
|
||||
@@ -26,7 +26,8 @@ export default function BaseFileInput({
|
||||
children,
|
||||
type
|
||||
}: BaseFileInputComponentProps) {
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const theme = useTheme();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
@@ -54,6 +55,7 @@ export default function BaseFileInput({
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (isArray(value)) {
|
||||
const blob = new Blob([value[0]], { type: value[0].type });
|
||||
@@ -73,6 +75,52 @@ export default function BaseFileInput({
|
||||
onChange(null);
|
||||
}
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
const file = event.dataTransfer.files[0];
|
||||
|
||||
// Check if file type is acceptable
|
||||
const isAcceptable = accept.some((acceptType) => {
|
||||
// Handle wildcards like "image/*"
|
||||
if (acceptType.endsWith('/*')) {
|
||||
const category = acceptType.split('/')[0];
|
||||
return file.type.startsWith(category);
|
||||
}
|
||||
return acceptType === file.type;
|
||||
});
|
||||
|
||||
if (isAcceptable) {
|
||||
onChange(file);
|
||||
} else {
|
||||
showSnackBar(
|
||||
`Invalid file type. Please use ${accept.join(', ')}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboardItems = event.clipboardData?.items ?? [];
|
||||
@@ -105,8 +153,15 @@ export default function BaseFileInput({
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
borderColor: isDragging ? theme.palette.primary.main : undefined,
|
||||
borderWidth: isDragging ? 2 : 1,
|
||||
borderStyle: isDragging ? 'dashed' : 'solid'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{preview ? (
|
||||
<Box
|
||||
@@ -137,17 +192,27 @@ export default function BaseFileInput({
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
color={
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey['300']
|
||||
: theme.palette.grey['600']
|
||||
}
|
||||
>
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
{isDragging ? (
|
||||
<Typography
|
||||
color={theme.palette.primary.main}
|
||||
variant="h6"
|
||||
align="center"
|
||||
>
|
||||
Drop your {type} here
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
color={
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey['300']
|
||||
: theme.palette.grey['600']
|
||||
}
|
||||
>
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, or drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
178
src/components/input/NumericInputWithUnit.tsx
Normal file
178
src/components/input/NumericInputWithUnit.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grid, Select, MenuItem } from '@mui/material';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import Qty from 'js-quantities';
|
||||
//
|
||||
|
||||
const siPrefixes: { [key: string]: number } = {
|
||||
'Default prefix': 1,
|
||||
k: 1000,
|
||||
M: 1000000,
|
||||
G: 1000000000,
|
||||
T: 1000000000000,
|
||||
m: 0.001,
|
||||
u: 0.000001,
|
||||
n: 0.000000001,
|
||||
p: 0.000000000001
|
||||
};
|
||||
|
||||
export default function NumericInputWithUnit(props: {
|
||||
value: { value: number; unit: string };
|
||||
disabled?: boolean;
|
||||
disableChangingUnit?: boolean;
|
||||
onOwnChange?: (value: { value: number; unit: string }) => void;
|
||||
defaultPrefix?: string;
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState(props.value.value);
|
||||
const [prefix, setPrefix] = useState(props.defaultPrefix || 'Default prefix');
|
||||
|
||||
// internal display unit
|
||||
const [unit, setUnit] = useState('');
|
||||
|
||||
// Whether user has overridden the unit
|
||||
const [userSelectedUnit, setUserSelectedUnit] = useState(false);
|
||||
const [unitKind, setUnitKind] = useState('');
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>([]);
|
||||
|
||||
const [disabled, setDisabled] = useState(props.disabled);
|
||||
const [disableChangingUnit, setDisableChangingUnit] = useState(
|
||||
props.disableChangingUnit
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDisabled(props.disabled);
|
||||
setDisableChangingUnit(props.disableChangingUnit);
|
||||
}, [props.disabled, props.disableChangingUnit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (unitKind != Qty(props.value.unit).kind()) {
|
||||
// Update the options for what units similar to this one are available
|
||||
const kind = Qty(props.value.unit).kind();
|
||||
let units: string[] = [];
|
||||
if (kind) {
|
||||
units = Qty.getUnits(kind);
|
||||
}
|
||||
|
||||
if (!units.includes(props.value.unit)) {
|
||||
units.push(props.value.unit);
|
||||
}
|
||||
|
||||
// Workaround because the lib doesn't list them
|
||||
if (kind == 'area') {
|
||||
units.push('km^2');
|
||||
units.push('mile^2');
|
||||
units.push('inch^2');
|
||||
units.push('m^2');
|
||||
units.push('cm^2');
|
||||
}
|
||||
setUnitOptions(units);
|
||||
setInputValue(props.value.value);
|
||||
setUnit(props.value.unit);
|
||||
setUnitKind(kind);
|
||||
setUserSelectedUnit(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userSelectedUnit) {
|
||||
if (!isNaN(props.value.value)) {
|
||||
const converted = Qty(props.value.value, props.value.unit).to(
|
||||
unit
|
||||
).scalar;
|
||||
setInputValue(converted);
|
||||
} else {
|
||||
setInputValue(props.value.value);
|
||||
}
|
||||
} else {
|
||||
setInputValue(props.value.value);
|
||||
setUnit(props.value.unit);
|
||||
}
|
||||
}, [props.value.value, props.value.unit, unit]);
|
||||
|
||||
const handleUserValueChange = (newValue: number) => {
|
||||
setInputValue(newValue);
|
||||
|
||||
if (props.onOwnChange) {
|
||||
try {
|
||||
const converted = Qty(newValue * siPrefixes[prefix], unit).to(
|
||||
props.value.unit
|
||||
).scalar;
|
||||
|
||||
props.onOwnChange({ unit: props.value.unit, value: converted });
|
||||
} catch (error) {
|
||||
console.error('Conversion error', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrefixChange = (newPrefix: string) => {
|
||||
setPrefix(newPrefix);
|
||||
};
|
||||
|
||||
const handleUserUnitChange = (newUnit: string) => {
|
||||
if (!newUnit) return;
|
||||
const oldInputValue = inputValue;
|
||||
const oldUnit = unit;
|
||||
setUnit(newUnit);
|
||||
setPrefix('Default prefix');
|
||||
|
||||
const convertedValue = Qty(oldInputValue * siPrefixes[prefix], oldUnit).to(
|
||||
newUnit
|
||||
).scalar;
|
||||
setInputValue(convertedValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextFieldWithDesc
|
||||
disabled={disabled}
|
||||
type="number"
|
||||
fullWidth
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
value={(inputValue / siPrefixes[prefix])
|
||||
.toFixed(9)
|
||||
.replace(/(\d*\.\d+?)0+$/, '$1')}
|
||||
onOwnChange={(value) => handleUserValueChange(parseFloat(value))}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disableChangingUnit}
|
||||
value={prefix}
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
onChange={(evt) => {
|
||||
handlePrefixChange(evt.target.value || '');
|
||||
}}
|
||||
>
|
||||
{Object.keys(siPrefixes).map((key) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{key}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={5}>
|
||||
<Select
|
||||
fullWidth
|
||||
disabled={disableChangingUnit}
|
||||
placeholder={'Unit'}
|
||||
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
|
||||
value={unit}
|
||||
onChange={(event) => {
|
||||
setUserSelectedUnit(true);
|
||||
handleUserUnitChange(event.target.value || '');
|
||||
}}
|
||||
>
|
||||
{unitOptions.map((key) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{key}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
@@ -5,11 +5,12 @@ import 'rc-slider/assets/index.css';
|
||||
import BaseFileInput from './BaseFileInput';
|
||||
import { BaseFileInputProps, formatTime } from './file-input-utils';
|
||||
|
||||
interface VideoFileInputProps extends BaseFileInputProps {
|
||||
interface VideoFileInputProps extends Omit<BaseFileInputProps, 'accept'> {
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
accept?: string[];
|
||||
}
|
||||
|
||||
export default function ToolVideoInput({
|
||||
@@ -17,6 +18,7 @@ export default function ToolVideoInput({
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100,
|
||||
accept = ['video/*', '.mkv'],
|
||||
...props
|
||||
}: VideoFileInputProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -38,7 +40,7 @@ export default function ToolVideoInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseFileInput {...props} type={'video'}>
|
||||
<BaseFileInput {...props} type={'video'} accept={accept}>
|
||||
{({ preview }) => (
|
||||
<Box
|
||||
sx={{
|
||||
|
@@ -13,10 +13,12 @@ export type GetGroupsType<T> = (
|
||||
|
||||
export default function ToolOptions<T extends FormikValues>({
|
||||
children,
|
||||
getGroups
|
||||
getGroups,
|
||||
vertical
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
getGroups: GetGroupsType<T> | null;
|
||||
vertical?: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const formikContext = useFormikContext<T>();
|
||||
@@ -49,6 +51,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
<Stack direction={'row'} spacing={2}>
|
||||
<ToolOptionGroups
|
||||
groups={getGroups({ ...formikContext, updateField }) ?? []}
|
||||
vertical={vertical}
|
||||
/>
|
||||
{children}
|
||||
</Stack>
|
||||
|
@@ -7,11 +7,13 @@ import React from 'react';
|
||||
export default function ResultFooter({
|
||||
handleDownload,
|
||||
handleCopy,
|
||||
disabled
|
||||
disabled,
|
||||
hideCopy
|
||||
}: {
|
||||
handleDownload: () => void;
|
||||
handleCopy: () => void;
|
||||
disabled?: boolean;
|
||||
hideCopy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack mt={1} direction={'row'} spacing={2}>
|
||||
@@ -22,13 +24,15 @@ export default function ResultFooter({
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={handleCopy}
|
||||
startIcon={<ContentPasteIcon />}
|
||||
>
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
{!hideCopy && (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={handleCopy}
|
||||
startIcon={<ContentPasteIcon />}
|
||||
>
|
||||
Copy to clipboard
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ export default function ToolFileResult({
|
||||
}: {
|
||||
title?: string;
|
||||
value: File | null;
|
||||
extension: string;
|
||||
extension?: string;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}) {
|
||||
@@ -50,9 +50,20 @@ export default function ToolFileResult({
|
||||
|
||||
const handleDownload = () => {
|
||||
if (value) {
|
||||
const hasExtension = value.name.includes('.');
|
||||
const filename = hasExtension ? value.name : `${value.name}.${extension}`;
|
||||
|
||||
let filename: string = value.name;
|
||||
if (extension) {
|
||||
// Split at the last period to separate filename and extension
|
||||
const parts = filename.split('.');
|
||||
// If there's more than one part (meaning there was a period)
|
||||
if (parts.length > 1) {
|
||||
// Remove the last part (the extension) and add the new extension
|
||||
parts.pop();
|
||||
filename = `${parts.join('.')}.${extension}`;
|
||||
} else {
|
||||
// No extension exists, just add it
|
||||
filename = `${filename}.${extension}`;
|
||||
}
|
||||
}
|
||||
const blob = new Blob([value], { type: value.type });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -162,6 +173,7 @@ export default function ToolFileResult({
|
||||
disabled={!value}
|
||||
handleCopy={handleCopy}
|
||||
handleDownload={handleDownload}
|
||||
hideCopy={fileType === 'video' || fileType === 'audio'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
@@ -1,21 +1,24 @@
|
||||
import { Box, TextField } from '@mui/material';
|
||||
import { Box, CircularProgress, TextField, Typography } from '@mui/material';
|
||||
import React, { useContext } from 'react';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
import InputHeader from '../InputHeader';
|
||||
import ResultFooter from './ResultFooter';
|
||||
import { replaceSpecialCharacters } from '@utils/string';
|
||||
import mime from 'mime';
|
||||
import { globalInputHeight } from '../../config/uiConfig';
|
||||
|
||||
export default function ToolTextResult({
|
||||
title = 'Result',
|
||||
value,
|
||||
extension = 'txt',
|
||||
keepSpecialCharacters
|
||||
keepSpecialCharacters,
|
||||
loading
|
||||
}: {
|
||||
title?: string;
|
||||
value: string;
|
||||
extension?: string;
|
||||
keepSpecialCharacters?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const handleCopy = () => {
|
||||
@@ -46,18 +49,37 @@ export default function ToolTextResult({
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
<TextField
|
||||
value={keepSpecialCharacters ? value : replaceSpecialCharacters(value)}
|
||||
fullWidth
|
||||
multiline
|
||||
sx={{
|
||||
'&.MuiTextField-root': {
|
||||
backgroundColor: 'background.paper'
|
||||
{loading ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: globalInputHeight
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ mt: 2 }}>
|
||||
Loading... This may take a moment.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
value={
|
||||
keepSpecialCharacters ? value : replaceSpecialCharacters(value)
|
||||
}
|
||||
}}
|
||||
rows={10}
|
||||
inputProps={{ 'data-testid': 'text-result' }}
|
||||
/>
|
||||
fullWidth
|
||||
multiline
|
||||
sx={{
|
||||
'&.MuiTextField-root': {
|
||||
backgroundColor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
rows={10}
|
||||
inputProps={{ 'data-testid': 'text-result' }}
|
||||
/>
|
||||
)}
|
||||
<ResultFooter handleCopy={handleCopy} handleDownload={handleDownload} />
|
||||
</Box>
|
||||
);
|
||||
|
@@ -3,6 +3,7 @@ export const globalDescriptionFontSize = 12;
|
||||
export const categoriesColors: string[] = [
|
||||
'#8FBC5D',
|
||||
'#3CB6E2',
|
||||
'#B17F59',
|
||||
'#FFD400',
|
||||
'#AB6993'
|
||||
];
|
||||
|
18
src/datatables/data/material_electrical_properties.ts
Normal file
18
src/datatables/data/material_electrical_properties.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
title: 'Material Electrical Properties',
|
||||
columns: {
|
||||
resistivity_20c: {
|
||||
title: 'Resistivity at 20°C',
|
||||
type: 'number',
|
||||
unit: 'Ω/m'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
Copper: {
|
||||
resistivity_20c: 1.68e-8
|
||||
},
|
||||
Aluminum: {
|
||||
resistivity_20c: 2.82e-8
|
||||
}
|
||||
}
|
||||
};
|
73
src/datatables/data/wire_gauge.ts
Normal file
73
src/datatables/data/wire_gauge.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { DataTable } from '../types';
|
||||
|
||||
const data: DataTable = {
|
||||
title: 'American Wire Gauge',
|
||||
columns: {
|
||||
diameter: {
|
||||
title: 'Diameter',
|
||||
type: 'number',
|
||||
unit: 'mm'
|
||||
},
|
||||
area: {
|
||||
title: 'Area',
|
||||
type: 'number',
|
||||
unit: 'mm2'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
'0000 AWG': { diameter: 11.684 },
|
||||
'000 AWG': { diameter: 10.405 },
|
||||
'00 AWG': { diameter: 9.266 },
|
||||
'0 AWG': { diameter: 8.251 },
|
||||
'(4/0) AWG': { diameter: 11.684 },
|
||||
'(3/0) AWG': { diameter: 10.405 },
|
||||
'(2/0) AWG': { diameter: 9.266 },
|
||||
'(1/0) AWG': { diameter: 8.251 },
|
||||
'1 AWG': { diameter: 7.348 },
|
||||
'2 AWG': { diameter: 6.544 },
|
||||
'3 AWG': { diameter: 5.827 },
|
||||
'4 AWG': { diameter: 5.189 },
|
||||
'5 AWG': { diameter: 4.621 },
|
||||
'6 AWG': { diameter: 4.115 },
|
||||
'7 AWG': { diameter: 3.665 },
|
||||
'8 AWG': { diameter: 3.264 },
|
||||
'9 AWG': { diameter: 2.906 },
|
||||
'10 AWG': { diameter: 2.588 },
|
||||
'11 AWG': { diameter: 2.305 },
|
||||
'12 AWG': { diameter: 2.053 },
|
||||
'13 AWG': { diameter: 1.828 },
|
||||
'14 AWG': { diameter: 1.628 },
|
||||
'15 AWG': { diameter: 1.45 },
|
||||
'16 AWG': { diameter: 1.291 },
|
||||
'17 AWG': { diameter: 1.15 },
|
||||
'18 AWG': { diameter: 1.024 },
|
||||
'19 AWG': { diameter: 0.912 },
|
||||
'20 AWG': { diameter: 0.812 },
|
||||
'21 AWG': { diameter: 0.723 },
|
||||
'22 AWG': { diameter: 0.644 },
|
||||
'23 AWG': { diameter: 0.573 },
|
||||
'24 AWG': { diameter: 0.511 },
|
||||
'25 AWG': { diameter: 0.455 },
|
||||
'26 AWG': { diameter: 0.405 },
|
||||
'27 AWG': { diameter: 0.361 },
|
||||
'28 AWG': { diameter: 0.321 },
|
||||
'29 AWG': { diameter: 0.286 },
|
||||
'30 AWG': { diameter: 0.255 },
|
||||
'31 AWG': { diameter: 0.227 },
|
||||
'32 AWG': { diameter: 0.202 },
|
||||
'33 AWG': { diameter: 0.18 },
|
||||
'34 AWG': { diameter: 0.16 },
|
||||
'35 AWG': { diameter: 0.143 },
|
||||
'36 AWG': { diameter: 0.127 },
|
||||
'37 AWG': { diameter: 0.113 },
|
||||
'38 AWG': { diameter: 0.101 },
|
||||
'39 AWG': { diameter: 0.0897 },
|
||||
'40 AWG': { diameter: 0.0799 }
|
||||
}
|
||||
};
|
||||
|
||||
for (const key in data.data) {
|
||||
data.data[key].area = Math.PI * (data.data[key].diameter / 2) ** 2;
|
||||
}
|
||||
|
||||
export default data;
|
8
src/datatables/index.ts
Normal file
8
src/datatables/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { DataTable } from './types.ts';
|
||||
|
||||
/* Used in case later we want any kind of computed extra data */
|
||||
export function dataTableLookup(table: DataTable, key: string): any {
|
||||
return table.data[key];
|
||||
}
|
||||
|
||||
export { DataTable };
|
17
src/datatables/types.ts
Normal file
17
src/datatables/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
Represents a set of rows indexed by a key.
|
||||
Used for calculator presets
|
||||
|
||||
*/
|
||||
export interface DataTable {
|
||||
title: string;
|
||||
/* A JSON schema properties */
|
||||
columns: {
|
||||
[key: string]: { title: string; type: string; unit: string };
|
||||
};
|
||||
data: {
|
||||
[key: string]: {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
}
|
173
src/lib/ghostscript/background-worker.js
Normal file
173
src/lib/ghostscript/background-worker.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { COMPRESS_ACTION, PROTECT_ACTION } from './worker-init';
|
||||
|
||||
function loadScript() {
|
||||
import('./gs-worker.js');
|
||||
}
|
||||
|
||||
var Module;
|
||||
|
||||
function compressPdf(dataStruct, responseCallback) {
|
||||
const compressionLevel = dataStruct.compressionLevel || 'medium';
|
||||
|
||||
// Set PDF settings based on compression level
|
||||
let pdfSettings;
|
||||
switch (compressionLevel) {
|
||||
case 'low':
|
||||
pdfSettings = '/printer'; // Higher quality, less compression
|
||||
break;
|
||||
case 'medium':
|
||||
pdfSettings = '/ebook'; // Medium quality and compression
|
||||
break;
|
||||
case 'high':
|
||||
pdfSettings = '/screen'; // Lower quality, higher compression
|
||||
break;
|
||||
default:
|
||||
pdfSettings = '/ebook'; // Default to medium
|
||||
}
|
||||
// first download the ps data
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', dataStruct.psDataURL);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function () {
|
||||
console.log('onload');
|
||||
// release the URL
|
||||
self.URL.revokeObjectURL(dataStruct.psDataURL);
|
||||
//set up EMScripten environment
|
||||
Module = {
|
||||
preRun: [
|
||||
function () {
|
||||
self.Module.FS.writeFile('input.pdf', new Uint8Array(xhr.response));
|
||||
}
|
||||
],
|
||||
postRun: [
|
||||
function () {
|
||||
var uarray = self.Module.FS.readFile('output.pdf', {
|
||||
encoding: 'binary'
|
||||
});
|
||||
var blob = new Blob([uarray], { type: 'application/octet-stream' });
|
||||
var pdfDataURL = self.URL.createObjectURL(blob);
|
||||
responseCallback({
|
||||
pdfDataURL: pdfDataURL,
|
||||
url: dataStruct.url,
|
||||
type: COMPRESS_ACTION
|
||||
});
|
||||
}
|
||||
],
|
||||
arguments: [
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dCompatibilityLevel=1.4',
|
||||
`-dPDFSETTINGS=${pdfSettings}`,
|
||||
'-DNOPAUSE',
|
||||
'-dQUIET',
|
||||
'-dBATCH',
|
||||
'-sOutputFile=output.pdf',
|
||||
'input.pdf'
|
||||
],
|
||||
print: function (text) {},
|
||||
printErr: function (text) {},
|
||||
totalDependencies: 0,
|
||||
noExitRuntime: 1
|
||||
};
|
||||
// Module.setStatus("Loading Ghostscript...");
|
||||
if (!self.Module) {
|
||||
self.Module = Module;
|
||||
loadScript();
|
||||
} else {
|
||||
self.Module['calledRun'] = false;
|
||||
self.Module['postRun'] = Module.postRun;
|
||||
self.Module['preRun'] = Module.preRun;
|
||||
self.Module.callMain();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function protectPdf(dataStruct, responseCallback) {
|
||||
const password = dataStruct.password || '';
|
||||
|
||||
// Validate password
|
||||
if (!password) {
|
||||
responseCallback({
|
||||
error: 'Password is required for encryption',
|
||||
url: dataStruct.url
|
||||
});
|
||||
return;
|
||||
}
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', dataStruct.psDataURL);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function () {
|
||||
console.log('onload');
|
||||
// release the URL
|
||||
self.URL.revokeObjectURL(dataStruct.psDataURL);
|
||||
//set up EMScripten environment
|
||||
Module = {
|
||||
preRun: [
|
||||
function () {
|
||||
self.Module.FS.writeFile('input.pdf', new Uint8Array(xhr.response));
|
||||
}
|
||||
],
|
||||
postRun: [
|
||||
function () {
|
||||
var uarray = self.Module.FS.readFile('output.pdf', {
|
||||
encoding: 'binary'
|
||||
});
|
||||
var blob = new Blob([uarray], { type: 'application/octet-stream' });
|
||||
var pdfDataURL = self.URL.createObjectURL(blob);
|
||||
responseCallback({
|
||||
pdfDataURL: pdfDataURL,
|
||||
url: dataStruct.url,
|
||||
type: PROTECT_ACTION
|
||||
});
|
||||
}
|
||||
],
|
||||
arguments: [
|
||||
'-sDEVICE=pdfwrite',
|
||||
'-dCompatibilityLevel=1.4',
|
||||
`-sOwnerPassword=${password}`,
|
||||
`-sUserPassword=${password}`,
|
||||
// Permissions (prevent copying/printing/etc)
|
||||
'-dEncryptionPermissions=-4',
|
||||
'-DNOPAUSE',
|
||||
'-dQUIET',
|
||||
'-dBATCH',
|
||||
'-sOutputFile=output.pdf',
|
||||
'input.pdf'
|
||||
],
|
||||
print: function (text) {},
|
||||
printErr: function (text) {},
|
||||
totalDependencies: 0,
|
||||
noExitRuntime: 1
|
||||
};
|
||||
// Module.setStatus("Loading Ghostscript...");
|
||||
if (!self.Module) {
|
||||
self.Module = Module;
|
||||
loadScript();
|
||||
} else {
|
||||
self.Module['calledRun'] = false;
|
||||
self.Module['postRun'] = Module.postRun;
|
||||
self.Module['preRun'] = Module.preRun;
|
||||
self.Module.callMain();
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
self.addEventListener('message', function ({ data: e }) {
|
||||
console.log('message', e);
|
||||
// e.data contains the message sent to the worker.
|
||||
if (e.target !== 'wasm') {
|
||||
return;
|
||||
}
|
||||
console.log('Message received from main script', e.data);
|
||||
const responseCallback = ({ pdfDataURL, type }) => {
|
||||
self.postMessage(pdfDataURL);
|
||||
};
|
||||
if (e.data.type === COMPRESS_ACTION) {
|
||||
compressPdf(e.data, responseCallback);
|
||||
} else if (e.data.type === PROTECT_ACTION) {
|
||||
protectPdf(e.data, responseCallback);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Worker ready');
|
5894
src/lib/ghostscript/gs-worker.js
Normal file
5894
src/lib/ghostscript/gs-worker.js
Normal file
File diff suppressed because it is too large
Load Diff
41
src/lib/ghostscript/worker-init.ts
Normal file
41
src/lib/ghostscript/worker-init.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const COMPRESS_ACTION = 'compress-pdf';
|
||||
export const PROTECT_ACTION = 'protect-pdf';
|
||||
|
||||
export async function compressWithGhostScript(dataStruct: {
|
||||
psDataURL: string;
|
||||
}): Promise<string> {
|
||||
const worker = getWorker();
|
||||
worker.postMessage({
|
||||
data: { ...dataStruct, type: COMPRESS_ACTION },
|
||||
target: 'wasm'
|
||||
});
|
||||
return getListener(worker);
|
||||
}
|
||||
|
||||
export async function protectWithGhostScript(dataStruct: {
|
||||
psDataURL: string;
|
||||
}): Promise<string> {
|
||||
const worker = getWorker();
|
||||
worker.postMessage({
|
||||
data: { ...dataStruct, type: PROTECT_ACTION },
|
||||
target: 'wasm'
|
||||
});
|
||||
return getListener(worker);
|
||||
}
|
||||
|
||||
const getListener = (worker: Worker): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const listener = (e: MessageEvent) => {
|
||||
resolve(e.data);
|
||||
worker.removeEventListener('message', listener);
|
||||
setTimeout(() => worker.terminate(), 0);
|
||||
};
|
||||
worker.addEventListener('message', listener);
|
||||
});
|
||||
};
|
||||
|
||||
const getWorker = () => {
|
||||
return new Worker(new URL('./background-worker.js', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
};
|
@@ -1,8 +1,8 @@
|
||||
import { Box, Divider, Stack, useTheme } from '@mui/material';
|
||||
import { Box, Divider, Stack, TextField, useTheme } from '@mui/material';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { getToolsByCategory } from '../../tools';
|
||||
import { filterTools, getToolsByCategory } from '../../tools';
|
||||
import Hero from 'components/Hero';
|
||||
import { capitalizeFirstLetter } from '@utils/string';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -12,12 +12,14 @@ import IconButton from '@mui/material/IconButton';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
import BackButton from '@components/BackButton';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
export default function Home() {
|
||||
export default function ToolsByCategory() {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const mainContentRef = React.useRef<HTMLDivElement>(null);
|
||||
const { categoryName } = useParams();
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (mainContentRef.current) {
|
||||
@@ -39,61 +41,81 @@ export default function Home() {
|
||||
</Box>
|
||||
<Divider sx={{ borderColor: theme.palette.primary.main }} />
|
||||
<Box ref={mainContentRef} mt={3} ml={{ xs: 1, md: 2, lg: 3 }} padding={3}>
|
||||
<Stack direction={'row'} alignItems={'center'} spacing={1}>
|
||||
<IconButton onClick={() => navigate('/')}>
|
||||
<ArrowBackIcon color={'primary'} />
|
||||
</IconButton>
|
||||
<Typography
|
||||
fontSize={22}
|
||||
color={theme.palette.primary.main}
|
||||
>{`All ${capitalizeFirstLetter(categoryName)} Tools`}</Typography>
|
||||
<Stack direction={'row'} justifyContent={'space-between'} spacing={2}>
|
||||
<Stack direction={'row'} alignItems={'center'} spacing={1}>
|
||||
<IconButton onClick={() => navigate('/')}>
|
||||
<ArrowBackIcon color={'primary'} />
|
||||
</IconButton>
|
||||
<Typography
|
||||
fontSize={22}
|
||||
color={theme.palette.primary.main}
|
||||
>{`All ${
|
||||
getToolsByCategory().find(
|
||||
(category) => category.type === categoryName
|
||||
)!.rawTitle
|
||||
} Tools`}</Typography>
|
||||
</Stack>
|
||||
<TextField
|
||||
placeholder={'Search'}
|
||||
InputProps={{
|
||||
endAdornment: <SearchIcon />,
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'background.paper',
|
||||
maxWidth: 400
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
/>
|
||||
</Stack>
|
||||
<Grid container spacing={2} mt={2}>
|
||||
{getToolsByCategory()
|
||||
.find(({ type }) => type === categoryName)
|
||||
?.tools?.map((tool, index) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={tool.path}>
|
||||
<Stack
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: `5px 4px 2px ${
|
||||
theme.palette.mode === 'dark' ? 'black' : '#E9E9ED'
|
||||
}`,
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.hover
|
||||
}
|
||||
}}
|
||||
onClick={() => navigate('/' + tool.path)}
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
spacing={2}
|
||||
padding={2}
|
||||
border={`1px solid ${theme.palette.background.default}`}
|
||||
borderRadius={2}
|
||||
>
|
||||
<Icon
|
||||
icon={tool.icon ?? 'ph:compass-tool-thin'}
|
||||
fontSize={'60px'}
|
||||
color={categoriesColors[index % categoriesColors.length]}
|
||||
/>
|
||||
<Box>
|
||||
<Link
|
||||
style={{
|
||||
fontSize: 20
|
||||
}}
|
||||
to={'/' + tool.path}
|
||||
>
|
||||
{tool.name}
|
||||
</Link>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
{tool.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
{filterTools(
|
||||
getToolsByCategory().find(({ type }) => type === categoryName)
|
||||
?.tools ?? [],
|
||||
searchTerm
|
||||
).map((tool, index) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={tool.path}>
|
||||
<Stack
|
||||
sx={{
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: `5px 4px 2px ${
|
||||
theme.palette.mode === 'dark' ? 'black' : '#E9E9ED'
|
||||
}`,
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.hover
|
||||
}
|
||||
}}
|
||||
onClick={() => navigate('/' + tool.path)}
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
spacing={2}
|
||||
padding={2}
|
||||
border={`1px solid ${theme.palette.background.default}`}
|
||||
borderRadius={2}
|
||||
>
|
||||
<Icon
|
||||
icon={tool.icon ?? 'ph:compass-tool-thin'}
|
||||
fontSize={'60px'}
|
||||
color={categoriesColors[index % categoriesColors.length]}
|
||||
/>
|
||||
<Box>
|
||||
<Link
|
||||
style={{
|
||||
fontSize: 20
|
||||
}}
|
||||
to={'/' + tool.path}
|
||||
>
|
||||
{tool.name}
|
||||
</Link>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
{tool.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
|
@@ -0,0 +1,125 @@
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { changeCsvSeparator } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
describe('changeCsvSeparator', () => {
|
||||
it('should change the separator from comma to semicolon', () => {
|
||||
const inputCsv = 'name,age,city\nJohn,30,New York';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: ',',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('name;age;city\nJohn;30;New York');
|
||||
});
|
||||
|
||||
it('should handle empty input gracefully', () => {
|
||||
const inputCsv = '';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: ',',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not modify the CSV if the separator is already correct', () => {
|
||||
const inputCsv = 'name;age;city\nJohn;30;New York';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: ';',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe(inputCsv);
|
||||
});
|
||||
|
||||
it('should handle custom separators', () => {
|
||||
const inputCsv = 'name|age|city\nJohn|30|New York';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('name;age;city\nJohn;30;New York');
|
||||
});
|
||||
|
||||
it('should quote all output values', () => {
|
||||
const inputCsv = 'name|age|city\nJohn|30|New York';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: true,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('"name";"age";"city"\n"John";"30";"New York"');
|
||||
});
|
||||
|
||||
it('should remove quotes from input values', () => {
|
||||
const inputCsv = '"name"|"age"|"city"\n"John"|"30"|"New York"';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('name;age;city\nJohn;30;New York');
|
||||
});
|
||||
|
||||
it('should handle emptylines', () => {
|
||||
const inputCsv = '"name"|"age"|"city"\n\n"John"|"30"|"New York"';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('name;age;city\nJohn;30;New York');
|
||||
});
|
||||
|
||||
it('should handle emptylines', () => {
|
||||
const inputCsv = '"name"|"age"|"city"\n\n"John"|"30"|"New York"';
|
||||
const options: InitialValuesType = {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
const result = changeCsvSeparator(inputCsv, options);
|
||||
expect(result).toBe('name;age;city\nJohn;30;New York');
|
||||
});
|
||||
});
|
213
src/pages/tools/csv/change-csv-separator/index.tsx
Normal file
213
src/pages/tools/csv/change-csv-separator/index.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
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 { changeCsvSeparator } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
inputSeparator: ',',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Change the CSV Delimiter to a Semicolon',
|
||||
description:
|
||||
'In this example, we change the column separator to the semicolon separator in a CSV file containing data about countries, their populations, and population densities. As you can see, the input CSV file uses the standard commas as separators. After specifying this delimiter in the source CSV options, we set a new CSV delimiter for the output file to a semicolon, resulting in a new CSV file that now uses semicolons ";" in the output. Such CSV files with semicolons are called SSV files (semicolon-separated values files)',
|
||||
sampleText: `country,population,density
|
||||
China,1412,152
|
||||
India,1408,428
|
||||
United States,331,37
|
||||
Indonesia,273,145
|
||||
Pakistan,231,232
|
||||
Brazil,214,26`,
|
||||
sampleResult: `country;population;density
|
||||
China;1412;152
|
||||
India;1408;428
|
||||
United States;331;37
|
||||
Indonesia;273;145
|
||||
Pakistan;231;232
|
||||
Brazil;214;26`,
|
||||
sampleOptions: {
|
||||
inputSeparator: ',',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
outputSeparator: ';',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: '"'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Restore a CSV File to the Standard Format',
|
||||
description:
|
||||
'In this example, a data scientist working with flowers was given an unusual CSV file that uses the vertical bar symbol as the field separator (such files are called PSV files – pipe-separated values files). To transform the file back to the standard comma-separated values (CSV) file, in the options, she set the input delimiter to "|" and the new delimiter to ",". She also wrapped the output fields in single quotes, enabled the option to remove empty lines from the input, and discarded comment lines starting with the "#" symbol.',
|
||||
sampleText: `species|height|days|temperature
|
||||
|
||||
Sunflower|50cm|30|25°C
|
||||
Rose|40cm|25|22°C
|
||||
Tulip|35cm|20|18°C
|
||||
Daffodil|30cm|15|20°C
|
||||
|
||||
Lily|45cm|28|23°C
|
||||
#pumpkin
|
||||
Brazil,214,26`,
|
||||
sampleResult: `'species','height','days','temperature'
|
||||
'Sunflower','50cm','30','25°C'
|
||||
'Rose','40cm','25','22°C'
|
||||
'Tulip','35cm','20','18°C'
|
||||
'Daffodil','30cm','15','20°C'
|
||||
'Lily','45cm','28','23°C'`,
|
||||
sampleOptions: {
|
||||
inputSeparator: '|',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
outputSeparator: ',',
|
||||
outputQuoteAll: true,
|
||||
OutputQuoteCharacter: "'"
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Plants vs. Zombies CSV',
|
||||
description:
|
||||
'In this example, we import CSV data with zombie characters from the game Plants vs. Zombies. The data includes zombies names, the level at which they first appear in the game, their health, damage, and speed. The data follows the standard CSV format, with commas serving as field separators. To change the readability of the file, we replace the usual comma delimiter with a slash symbol, creating a slash-separated values file.',
|
||||
sampleText: `zombie_name,first_seen,health,damage,speed
|
||||
Normal Zombie,Level 1-1,181,100,4.7
|
||||
Conehead Zombie,Level 1-3,551,100,4.7
|
||||
Buckethead Zombi,Level 1-8,1281,100,4.7
|
||||
Newspaper Zombie,Level 2-1,331,100,4.7
|
||||
Football Zombie,Level 2-6,1581,100,2.5
|
||||
Dancing Zombie,Level 2-8,335,100,1.5
|
||||
Zomboni,Level 3-6,1151,Instant-kill,varies
|
||||
Catapult Zombie,Level 5-6,651,75,2.5
|
||||
Gargantuar,Level 5-8,3000,Instant-kill,4.7`,
|
||||
sampleResult: `zombie_name/first_seen/health/damage/speed
|
||||
Normal Zombie/Level 1-1/181/100/4.7
|
||||
Conehead Zombie/Level 1-3/551/100/4.7
|
||||
Buckethead Zombi/Level 1-8/1281/100/4.7
|
||||
Newspaper Zombie/Level 2-1/331/100/4.7
|
||||
Football Zombie/Level 2-6/1581/100/2.5
|
||||
Dancing Zombie/Level 2-8/335/100/1.5
|
||||
Zomboni/Level 3-6/1151/Instant-kill/varies
|
||||
Catapult Zombie/Level 5-6/651/75/2.5
|
||||
Gargantuar/Level 5-8/3000/Instant-kill/4.7`,
|
||||
sampleOptions: {
|
||||
inputSeparator: ',',
|
||||
inputQuoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
outputSeparator: '/',
|
||||
outputQuoteAll: false,
|
||||
OutputQuoteCharacter: "'"
|
||||
}
|
||||
}
|
||||
];
|
||||
export default function ChangeCsvDelimiter({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (values: InitialValuesType, input: string) => {
|
||||
setResult(changeCsvSeparator(input, values));
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Adjust CSV input options',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.inputSeparator}
|
||||
onOwnChange={(val) => updateField('inputSeparator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV input file.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.inputQuoteCharacter}
|
||||
onOwnChange={(val) => updateField('inputQuoteCharacter', val)}
|
||||
description={
|
||||
'Enter the quote character used to quote the CSV input fields.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.commentCharacter}
|
||||
onOwnChange={(val) => updateField('commentCharacter', val)}
|
||||
description={
|
||||
'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.'
|
||||
}
|
||||
/>
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyLines}
|
||||
onChange={(value) => updateField('emptyLines', value)}
|
||||
title="Delete Lines with No Data"
|
||||
description="Remove empty lines from CSV input file."
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Output options',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.outputSeparator}
|
||||
onOwnChange={(val) => updateField('outputSeparator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV output file.'
|
||||
}
|
||||
/>
|
||||
<CheckboxWithDesc
|
||||
checked={values.outputQuoteAll}
|
||||
onChange={(value) => updateField('outputQuoteAll', value)}
|
||||
title="Quote All Output Fields"
|
||||
description="Wrap all fields of the output CSV file in quotes"
|
||||
/>
|
||||
{values.outputQuoteAll && (
|
||||
<TextFieldWithDesc
|
||||
value={values.OutputQuoteCharacter}
|
||||
onOwnChange={(val) => updateField('OutputQuoteCharacter', val)}
|
||||
description={
|
||||
'Enter the quote character used to quote the CSV output fields.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput title={'Input CSV'} value={input} onChange={setInput} />
|
||||
}
|
||||
resultComponent={<ToolTextResult title={'Output CSV'} value={result} />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/csv/change-csv-separator/meta.ts
Normal file
15
src/pages/tools/csv/change-csv-separator/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('csv', {
|
||||
name: 'Change csv separator',
|
||||
path: 'change-csv-separator',
|
||||
icon: 'material-symbols:split-scene-rounded',
|
||||
description:
|
||||
'Just upload your CSV file in the form below and it will automatically get a new column delimiter character. In the tool options, you can specify which delimiter and quote characters are used in the source CSV file and customize the desired delimiter and quote characters for the output CSV. You can also filter the input CSV before the conversion process and skip blank lines and comment lines.',
|
||||
shortDescription: 'Quickly change the CSV column delimiter to a new symbol.',
|
||||
keywords: ['change', 'csv', 'sepa rator'],
|
||||
longDescription:
|
||||
'This tool changes the field separator in CSV (Comma Separated Values) files. This is useful because different programs may use different default separators. While a comma is the most common separator in CSV files, some programs require files to be tab-separated (TSV), semicolon-separated (SSV), pipe-separated (PSV), or have another separation symbol. The default comma may not be so convenient as a delimiter in CSV files because commas are frequently present within fields. In such cases, it can be difficult and confusing to distinguish between commas as delimiters and commas as punctuation symbols. By replacing the comma with another delimiter, you can convert the file into a more easily readable and parsable format. In the options section of this tool, you can configure both the input and output CSV file formats. For the input CSV, you can specify its current delimiter (by default, it is a comma) and also indicate the quotation mark character used to wrap fields. For the output CSV, you can set a new delimiter, choose a new quotation mark character, and optionally enclose all the fields in quotes. Additionally, you have the option to remove empty lines from the input CSV and eliminate comment lines that start with a specified character (usually a hash "#" or double slashes "//"). Csv-abulous!',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
31
src/pages/tools/csv/change-csv-separator/service.ts
Normal file
31
src/pages/tools/csv/change-csv-separator/service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { splitCsv } from '@utils/csv';
|
||||
|
||||
export function changeCsvSeparator(
|
||||
input: string,
|
||||
options: InitialValuesType
|
||||
): string {
|
||||
if (!input) return '';
|
||||
|
||||
const rows = splitCsv(
|
||||
input,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
options.emptyLines,
|
||||
options.inputSeparator,
|
||||
options.inputQuoteCharacter
|
||||
);
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
return row
|
||||
.map((cell) => {
|
||||
if (options.outputQuoteAll) {
|
||||
return `${options.OutputQuoteCharacter}${cell}${options.OutputQuoteCharacter}`;
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
.join(options.outputSeparator);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
9
src/pages/tools/csv/change-csv-separator/types.ts
Normal file
9
src/pages/tools/csv/change-csv-separator/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type InitialValuesType = {
|
||||
inputSeparator: string;
|
||||
inputQuoteCharacter: string;
|
||||
commentCharacter: string;
|
||||
emptyLines: boolean;
|
||||
outputSeparator: string;
|
||||
outputQuoteAll: boolean;
|
||||
OutputQuoteCharacter: string;
|
||||
};
|
@@ -1,9 +1,4 @@
|
||||
function unquoteIfQuoted(value: string, quoteCharacter: string): string {
|
||||
if (value.startsWith(quoteCharacter) && value.endsWith(quoteCharacter)) {
|
||||
return value.slice(1, -1); // Remove first and last character
|
||||
}
|
||||
return value;
|
||||
}
|
||||
import { unquoteIfQuoted } from '@utils/string';
|
||||
export function csvToTsv(
|
||||
input: string,
|
||||
delimiter: string,
|
||||
|
48
src/pages/tools/csv/csv-to-yaml/csv-to-yaml.service.test.ts
Normal file
48
src/pages/tools/csv/csv-to-yaml/csv-to-yaml.service.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { main } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
// filepath: c:\CODE\omni-tools\src\pages\tools\csv\csv-to-yaml\csv-to-yaml.service.test.ts
|
||||
describe('main', () => {
|
||||
const defaultOptions: InitialValuesType = {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: false,
|
||||
headerRow: true,
|
||||
spaces: 2
|
||||
};
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
const result = main('', defaultOptions);
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('should return this if header is set to false', () => {
|
||||
const options = { ...defaultOptions, headerRow: false };
|
||||
const result = main('John,30\nEmma,50', options);
|
||||
expect(result).toEqual('-\n - John\n - 30\n-\n - Emma\n - 50');
|
||||
});
|
||||
|
||||
it('should return this header is set to true', () => {
|
||||
const options = { ...defaultOptions };
|
||||
const result = main('Name,Age\nJohn,30\nEmma,50', options);
|
||||
expect(result).toEqual(
|
||||
'-\n Name: John\n Age: 30\n-\n Name: Emma\n Age: 50'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return this header is set to true and comment flag set', () => {
|
||||
const options = { ...defaultOptions, commentcharacter: '#' };
|
||||
const result = main('Name,Age\nJohn,30\n#Emma,50', options);
|
||||
expect(result).toEqual('-\n Name: John\n Age: 30');
|
||||
});
|
||||
|
||||
it('should return this header is set to true and spaces is set to 3', () => {
|
||||
const options = { ...defaultOptions, spaces: 3 };
|
||||
const result = main('Name,Age\nJohn,30\nEmma,50', options);
|
||||
expect(result).toEqual(
|
||||
'-\n Name: John\n Age: 30\n-\n Name: Emma\n Age: 50'
|
||||
);
|
||||
});
|
||||
});
|
206
src/pages/tools/csv/csv-to-yaml/index.tsx
Normal file
206
src/pages/tools/csv/csv-to-yaml/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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 { main } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
headerRow: true,
|
||||
spaces: 2
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Convert Music Playlist CSV to YAML',
|
||||
description:
|
||||
'In this example, we transform a short CSV file containing a music playlist into structured YAML data. The input CSV contains five records with three columns each and the output YAML contains five lists of lists (one list for each CSV record). In YAML, lists start with the "-" symbol and the nested lists are indented with two spaces',
|
||||
sampleText: `The Beatles,"Yesterday",Pop Rock
|
||||
Queen,"Bohemian Rhapsody",Rock
|
||||
Nirvana,"Smells Like Teen Spirit",Grunge
|
||||
Michael Jackson,"Billie Jean",Pop
|
||||
Stevie Wonder,"Superstition",Funk`,
|
||||
sampleResult: `-
|
||||
- The Beatles
|
||||
- Yesterday
|
||||
- Pop Rock
|
||||
-
|
||||
- Queen
|
||||
- Bohemian Rhapsody
|
||||
- Rock
|
||||
-
|
||||
- Nirvana
|
||||
- Smells Like Teen Spirit
|
||||
- Grunge
|
||||
-
|
||||
- Michael Jackson
|
||||
- Billie Jean
|
||||
- Pop
|
||||
-
|
||||
- Stevie Wonder
|
||||
- Superstition
|
||||
- Funk`,
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
headerRow: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Planetary CSV Data',
|
||||
description:
|
||||
'In this example, we are working with CSV data that summarizes key properties of three planets in our solar system. The data consists of three columns with headers "planet", "relative mass" (with "1" being the mass of earth), and "satellites". To preserve the header names in the output YAML data, we enable the "Transform Headers" option, creating a YAML file that contains a list of YAML objects, where each object has three keys: "planet", "relative mass", and "satellites".',
|
||||
sampleText: `planet,relative mass,satellites
|
||||
Venus,0.815,0
|
||||
Earth,1.000,1
|
||||
Mars,0.107,2`,
|
||||
sampleResult: `-
|
||||
planet: Venus
|
||||
relative mass: 0.815
|
||||
satellites: '0'
|
||||
-
|
||||
planet: Earth
|
||||
relative mass: 1.000
|
||||
satellites: '1'
|
||||
-
|
||||
planet: Mars
|
||||
relative mass: 0.107
|
||||
satellites: '2'`,
|
||||
sampleOptions: {
|
||||
...initialValues
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Convert Non-standard CSV to YAML',
|
||||
description:
|
||||
'In this example, we convert a CSV file with non-standard formatting into a regular YAML file. The input data uses a semicolon as a separator for the "product", "quantity", and "price" fields. It also contains empty lines and lines that are commented out. To make the program work with this custom CSV file, we input the semicolon symbol in the CSV delimiter options. To skip comments, we specify "#" as the symbol that starts comments. And to remove empty lines, we activate the option for skipping blank lines (that do not contain any symbols). In the output, we obtain a YAML file that contains a list of three objects, which use CSV headers as keys. Additionally, the objects in the YAML file are indented with four spaces.',
|
||||
sampleText: `item;quantity;price
|
||||
milk;2;3.50
|
||||
|
||||
#eggs;12;2.99
|
||||
bread;1;4.25
|
||||
#apples;4;1.99
|
||||
cheese;1;8.99`,
|
||||
sampleResult: `-
|
||||
item: milk
|
||||
quantity: 2
|
||||
price: 3.50
|
||||
-
|
||||
item: bread
|
||||
quantity: 1
|
||||
price: 4.25
|
||||
-
|
||||
item: cheese
|
||||
quantity: 1
|
||||
price: 8.99`,
|
||||
sampleOptions: {
|
||||
...initialValues,
|
||||
csvSeparator: ';'
|
||||
}
|
||||
}
|
||||
];
|
||||
export default function CsvToYaml({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (optionsValues: InitialValuesType, input: string) => {
|
||||
setResult(main(input, optionsValues));
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Adjust CSV input',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.csvSeparator}
|
||||
onOwnChange={(val) => updateField('csvSeparator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV file.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.quoteCharacter}
|
||||
onOwnChange={(val) => updateField('quoteCharacter', val)}
|
||||
description={
|
||||
'Enter the quote character used to quote the CSV fields.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.commentCharacter}
|
||||
onOwnChange={(val) => updateField('commentCharacter', val)}
|
||||
description={
|
||||
'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Conversion Options',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.headerRow}
|
||||
onChange={(value) => updateField('headerRow', value)}
|
||||
title="Use Headers"
|
||||
description="Keep the first row as column names."
|
||||
/>
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyLines}
|
||||
onChange={(value) => updateField('emptyLines', value)}
|
||||
title="Ignore Lines with No Data"
|
||||
description="Enable to prevent the conversion of empty lines in the input CSV file."
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Adjust YAML indentation',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.spaces}
|
||||
type="number"
|
||||
onOwnChange={(val) => updateField('spaces', Number(val))}
|
||||
inputProps={{ min: 1 }}
|
||||
description={
|
||||
'Set the number of spaces to use for YAML indentation.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput title={'Input CSV'} value={input} onChange={setInput} />
|
||||
}
|
||||
resultComponent={<ToolTextResult title={'Output YAML'} value={result} />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/csv/csv-to-yaml/meta.ts
Normal file
15
src/pages/tools/csv/csv-to-yaml/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('csv', {
|
||||
name: 'Csv to yaml',
|
||||
path: 'csv-to-yaml',
|
||||
icon: 'nonicons:yaml-16',
|
||||
description:
|
||||
'Just upload your CSV file in the form below and it will automatically get converted to a YAML file. In the tool options, you can specify the field delimiter character, field quote character, and comment character to adapt the tool to custom CSV formats. Additionally, you can select the output YAML format: one that preserves CSV headers or one that excludes CSV headers.',
|
||||
shortDescription: 'Quickly convert a CSV file to a YAML file.',
|
||||
keywords: ['csv', 'to', 'yaml'],
|
||||
longDescription:
|
||||
'This tool transforms CSV (Comma Separated Values) data into the YAML (Yet Another Markup Language) data. CSV is a simple, tabular format that is used to represent matrix-like data types consisting of rows and columns. YAML, on the other hand, is a more advanced format (actually a superset of JSON), which creates more human-readable data for serialization, and it supports lists, dictionaries, and nested objects. This program supports various input CSV formats – the input data can be comma-separated (default), semicolon-separated, pipe-separated, or use another completely different delimiter. You can specify the exact delimiter your data uses in the options. Similarly, in the options, you can specify the quote character that is used to wrap CSV fields (by default a double-quote symbol). You can also skip lines that start with comments by specifying the comment symbols in the options. This allows you to keep your data clean by skipping unnecessary lines. There are two ways to convert CSV to YAML. The first method converts each CSV row into a YAML list. The second method extracts headers from the first CSV row and creates YAML objects with keys based on these headers. You can also customize the output YAML format by specifying the number of spaces for indenting YAML structures. If you need to perform the reverse conversion, that is, transform YAML into CSV, you can use our Convert YAML to CSV tool. Csv-abulous!',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
85
src/pages/tools/csv/csv-to-yaml/service.ts
Normal file
85
src/pages/tools/csv/csv-to-yaml/service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { getCsvHeaders, splitCsv } from '@utils/csv';
|
||||
import { unquoteIfQuoted } from '@utils/string';
|
||||
|
||||
function toYaml(
|
||||
input: Record<string, string>[] | string[][],
|
||||
indentSpaces: number = 2
|
||||
): string {
|
||||
if (indentSpaces == 0) {
|
||||
throw new Error('Indent spaces must be greater than zero');
|
||||
}
|
||||
const indent = ' '.repeat(indentSpaces);
|
||||
|
||||
if (
|
||||
Array.isArray(input) &&
|
||||
input.length > 0 &&
|
||||
typeof input[0] === 'object' &&
|
||||
!Array.isArray(input[0])
|
||||
) {
|
||||
return (input as Record<string, string>[])
|
||||
.map((obj) => {
|
||||
const lines = Object.entries(obj)
|
||||
.map(([key, value]) => `${indent}${key}: ${value}`)
|
||||
.join('\n');
|
||||
return `-\n${lines}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// If input is string[][].
|
||||
if (Array.isArray(input) && Array.isArray(input[0])) {
|
||||
return (input as string[][])
|
||||
.map((row) => {
|
||||
const inner = row.map((cell) => `${indent}- ${cell}`).join('\n');
|
||||
return `-\n${inner}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return 'invalid input';
|
||||
}
|
||||
|
||||
export function main(input: string, options: InitialValuesType): string {
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const rows = splitCsv(
|
||||
input,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
options.emptyLines,
|
||||
options.csvSeparator,
|
||||
options.quoteCharacter
|
||||
);
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.forEach((cell, cellIndex) => {
|
||||
row[cellIndex] = unquoteIfQuoted(cell, options.quoteCharacter);
|
||||
});
|
||||
});
|
||||
|
||||
if (options.headerRow) {
|
||||
const headerRow = getCsvHeaders(
|
||||
input,
|
||||
options.csvSeparator,
|
||||
options.quoteCharacter,
|
||||
options.commentCharacter
|
||||
);
|
||||
headerRow.forEach((header, headerIndex) => {
|
||||
headerRow[headerIndex] = unquoteIfQuoted(header, options.quoteCharacter);
|
||||
});
|
||||
|
||||
const result: Record<string, string>[] = rows.slice(1).map((row) => {
|
||||
const entry: Record<string, string> = {};
|
||||
headerRow.forEach((header, headerIndex) => {
|
||||
entry[header] = row[headerIndex] ?? '';
|
||||
});
|
||||
return entry;
|
||||
});
|
||||
return toYaml(result, options.spaces);
|
||||
}
|
||||
|
||||
return toYaml(rows, options.spaces);
|
||||
}
|
8
src/pages/tools/csv/csv-to-yaml/types.ts
Normal file
8
src/pages/tools/csv/csv-to-yaml/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type InitialValuesType = {
|
||||
csvSeparator: string;
|
||||
quoteCharacter: string;
|
||||
commentCharacter: string;
|
||||
emptyLines: boolean;
|
||||
headerRow: boolean;
|
||||
spaces: number;
|
||||
};
|
198
src/pages/tools/csv/find-incomplete-csv-records/index.tsx
Normal file
198
src/pages/tools/csv/find-incomplete-csv-records/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
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 { findIncompleteCsvRecords } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
emptyValues: true,
|
||||
messageLimit: false,
|
||||
messageNumber: 10
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'CSV Completeness Check',
|
||||
description:
|
||||
'In this example, we upload a simple CSV file containing names, surnames, and dates of birth. The tool analyzes the data and displays a green "Complete CSV" badge as it finds that there are no missing values or empty records. To say it differently, this check confirms that all rows and columns have the expected number of values in the data and the file is ready for use in any software that imports CSV files without hiccups.',
|
||||
sampleText: `name,surname,dob
|
||||
John,Warner,1990-05-15
|
||||
Lily,Meadows,1985-12-20
|
||||
Jaime,Crane,1993-01-23
|
||||
Jeri,Carroll,2000-11-07
|
||||
Simon,Harper,2013-04-10`,
|
||||
sampleResult: `The Csv input is complete.`,
|
||||
sampleOptions: {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
emptyValues: true,
|
||||
messageLimit: false,
|
||||
messageNumber: 10
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Find Missing Fields in Broken CSV',
|
||||
description:
|
||||
'In this example, we find the missing fields in a CSV file containing city names, time zones, and standard time information. As a result of the analysis, we see a red badge in the output and a text list of missing values in the dataset. The file has missing values on two rows: row 3 lacks standard time data (column 3), and row 5 lacks time zone and standard time data (columns 2 and 3).',
|
||||
sampleText: `City,Time Zone,Standard Time
|
||||
London,UTC+00:00,GMT
|
||||
Chicago,UTC-06:00
|
||||
Tokyo,UTC+09:00,JST
|
||||
Sydney
|
||||
Berlin,UTC+01:00,CET`,
|
||||
sampleResult: `Title: Found missing column(s) on line 3
|
||||
Message: Line 3 has 1 missing column(s).
|
||||
|
||||
Title: Found missing column(s) on line 5
|
||||
Message: Line 5 has 2 missing column(s).`,
|
||||
sampleOptions: {
|
||||
csvSeparator: ',',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
emptyValues: false,
|
||||
messageLimit: true,
|
||||
messageNumber: 10
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Detect Empty and Missing Values',
|
||||
description:
|
||||
'This example checks a data file containing information astronomical data about constellations. Not only does it find incomplete records but also detects all empty fields by activating the "Find Empty Values" checkbox. The empty fields are those that have zero length or contain just whitespace. Such fields contain no information. Additionally, since this file uses semicolons instead of commas for separators, we specify the ";" symbol in the options to make the program work with SSV (Semicolon-Separated Values) data. As a result, the program identifies three empty fields and one row with missing data.',
|
||||
sampleText: `Abbreviation;Constellation;Main stars
|
||||
|
||||
Cas;Cassiopeia;5
|
||||
Cep;Cepheus;7
|
||||
;Andromeda;16
|
||||
|
||||
Cyg;;
|
||||
Del;Delphinus`,
|
||||
sampleResult: `Title: Found missing values on line 4
|
||||
Message: Empty values on line 4: column 1.
|
||||
|
||||
Title: Found missing values on line 5
|
||||
Message: Empty values on line 5: column 2, column 3.
|
||||
|
||||
Title: Found missing column(s) on line 6
|
||||
Message: Line 6 has 1 missing column(s).`,
|
||||
sampleOptions: {
|
||||
csvSeparator: ';',
|
||||
quoteCharacter: '"',
|
||||
commentCharacter: '#',
|
||||
emptyLines: true,
|
||||
emptyValues: true,
|
||||
messageLimit: true,
|
||||
messageNumber: 10
|
||||
}
|
||||
}
|
||||
];
|
||||
export default function FindIncompleteCsvRecords({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (values: InitialValuesType, input: string) => {
|
||||
setResult(findIncompleteCsvRecords(input, values));
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Csv input Options',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.csvSeparator}
|
||||
onOwnChange={(val) => updateField('csvSeparator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV input file.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.quoteCharacter}
|
||||
onOwnChange={(val) => updateField('quoteCharacter', val)}
|
||||
description={
|
||||
'Enter the quote character used to quote the CSV input fields.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.commentCharacter}
|
||||
onOwnChange={(val) => updateField('commentCharacter', val)}
|
||||
description={
|
||||
'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Checking Options',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyLines}
|
||||
onChange={(value) => updateField('emptyLines', value)}
|
||||
title="Delete Lines with No Data"
|
||||
description="Remove empty lines from CSV input file."
|
||||
/>
|
||||
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyValues}
|
||||
onChange={(value) => updateField('emptyValues', value)}
|
||||
title="Find Empty Values"
|
||||
description="Display a message about CSV fields that are empty (These are not missing fields but fields that contain nothing)."
|
||||
/>
|
||||
|
||||
<CheckboxWithDesc
|
||||
checked={values.messageLimit}
|
||||
onChange={(value) => updateField('messageLimit', value)}
|
||||
title="Limit number of messages"
|
||||
/>
|
||||
|
||||
{values.messageLimit && (
|
||||
<TextFieldWithDesc
|
||||
value={values.messageNumber}
|
||||
onOwnChange={(val) => updateField('messageNumber', Number(val))}
|
||||
type="number"
|
||||
inputProps={{ min: 1 }}
|
||||
description={'Set the limit of number of messages in the output.'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput title={'Input CSV'} value={input} onChange={setInput} />
|
||||
}
|
||||
resultComponent={<ToolTextResult title={'CSV Status'} value={result} />}
|
||||
initialValues={initialValues}
|
||||
exampleCards={exampleCards}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
16
src/pages/tools/csv/find-incomplete-csv-records/meta.ts
Normal file
16
src/pages/tools/csv/find-incomplete-csv-records/meta.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('csv', {
|
||||
name: 'Find incomplete csv records',
|
||||
path: 'find-incomplete-csv-records',
|
||||
icon: 'tdesign:search-error',
|
||||
description:
|
||||
'Just upload your CSV file in the form below and this tool will automatically check if none of the rows or columns are missing values. In the tool options, you can adjust the input file format (specify the delimiter, quote character, and comment character). Additionally, you can enable checking for empty values, skip empty lines, and set a limit on the number of error messages in the output.',
|
||||
shortDescription:
|
||||
'Quickly find rows and columns in CSV that are missing values.',
|
||||
keywords: ['find', 'incomplete', 'csv', 'records'],
|
||||
longDescription:
|
||||
'This tool checks the completeness of CSV (Comma Separated Values) files and identifies incomplete records within the data. It finds rows and columns where one or more values are missing and displays their positions in the output so that you can quickly find and fix your CSV file. A valid CSV file has the same number of values (fields) in all rows and the same number of values (fields) in all columns. If the CSV you load in this tool is complete, the program will notify you with a green badge. If at least one value is missing in any row or column, the program will show a red badge and indicate the exact location of the missing value. If the CSV file has a field with no characters in it, then such a field is called an empty field. It is not a missing field, just empty as it contains nothing. You can activate the "Find Empty Values" checkbox in the options to identify all such fields in the CSV. If the file contains empty lines, you can ignore them with the "Skip Empty Lines" option or check them for completeness along with other lines. You can also configure the delimiter, quote, and comment characters in the options. This allows you to adapt to other file formats besides CSV, such as TSV (Tab Separated Values), SSV (Semicolon Separated Values), or PSV (Pipe Separated Values). If the file has too many incomplete or empty records, you can set a limit on the output messages to display, for example, 5, 10, or 20 messages. If you want to quickly fill in the missing data with default values, you can use our Fill Incomplete CSV Records tool. Csv-abulous!',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
80
src/pages/tools/csv/find-incomplete-csv-records/service.ts
Normal file
80
src/pages/tools/csv/find-incomplete-csv-records/service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { splitCsv } from '@utils/csv';
|
||||
|
||||
function generateMessage(
|
||||
row: string[],
|
||||
lineIndex: number,
|
||||
maxLength: number,
|
||||
emptyLines: boolean,
|
||||
emptyValues: boolean
|
||||
) {
|
||||
const lineNumber = lineIndex + 1;
|
||||
// check if empty lines are allowed
|
||||
if (!emptyLines && row.length === 1 && row[0] === '')
|
||||
return { title: 'Missing Line', message: `Line ${lineNumber} is empty.` };
|
||||
|
||||
// if row legth is less than maxLength it means that there are missing columns
|
||||
if (row.length < maxLength)
|
||||
return {
|
||||
title: `Found missing column(s) on line ${lineNumber}`,
|
||||
message: `Line ${lineNumber} has ${
|
||||
maxLength - row.length
|
||||
} missing column(s).`
|
||||
};
|
||||
|
||||
// if row length is equal to maxLength we should check if there are empty values
|
||||
if (row.length == maxLength && emptyValues) {
|
||||
let missingValues = false;
|
||||
let message = `Empty values on line ${lineNumber}: `;
|
||||
row.forEach((cell, index) => {
|
||||
if (cell.trim() === '') {
|
||||
missingValues = true;
|
||||
message += `column ${index + 1}, `;
|
||||
}
|
||||
});
|
||||
if (missingValues)
|
||||
return {
|
||||
title: `Found missing values on line ${lineNumber}`,
|
||||
message: message.slice(0, -2) + '.'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
export function findIncompleteCsvRecords(
|
||||
input: string,
|
||||
options: InitialValuesType
|
||||
): string {
|
||||
if (!input) return '';
|
||||
|
||||
if (options.messageLimit && options.messageNumber <= 0)
|
||||
throw new Error('Message number must be greater than 0');
|
||||
|
||||
const rows = splitCsv(
|
||||
input,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
options.emptyLines,
|
||||
options.csvSeparator,
|
||||
options.quoteCharacter
|
||||
);
|
||||
const maxLength = Math.max(...rows.map((row) => row.length));
|
||||
const messages = rows
|
||||
.map((row, index) =>
|
||||
generateMessage(
|
||||
row,
|
||||
index,
|
||||
maxLength,
|
||||
options.emptyLines,
|
||||
options.emptyValues
|
||||
)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.map((msg) => `Title: ${msg!.title}\nMessage: ${msg!.message}`);
|
||||
|
||||
return messages.length > 0
|
||||
? options.messageLimit
|
||||
? messages.slice(0, options.messageNumber).join('\n\n')
|
||||
: messages.join('\n\n')
|
||||
: 'The Csv input is complete.';
|
||||
}
|
9
src/pages/tools/csv/find-incomplete-csv-records/types.ts
Normal file
9
src/pages/tools/csv/find-incomplete-csv-records/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type InitialValuesType = {
|
||||
csvSeparator: string;
|
||||
quoteCharacter: string;
|
||||
commentCharacter: string;
|
||||
emptyLines: boolean;
|
||||
emptyValues: boolean;
|
||||
messageLimit: boolean;
|
||||
messageNumber: number;
|
||||
};
|
@@ -1,6 +1,19 @@
|
||||
import { tool as findIncompleteCsvRecords } from './find-incomplete-csv-records/meta';
|
||||
import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta';
|
||||
import { tool as csvToYaml } from './csv-to-yaml/meta';
|
||||
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 swapCsvColumns } from './swap-csv-columns/meta';
|
||||
|
||||
export const csvTools = [csvToJson, csvToXml, csvToRowsColumns, csvToTsv];
|
||||
export const csvTools = [
|
||||
csvToJson,
|
||||
csvToXml,
|
||||
csvToRowsColumns,
|
||||
csvToTsv,
|
||||
swapCsvColumns,
|
||||
csvToYaml,
|
||||
ChangeCsvDelimiter,
|
||||
findIncompleteCsvRecords
|
||||
];
|
||||
|
297
src/pages/tools/csv/swap-csv-columns/index.tsx
Normal file
297
src/pages/tools/csv/swap-csv-columns/index.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolTextInput from '@components/input/ToolTextInput';
|
||||
import ToolTextResult from '@components/result/ToolTextResult';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
import SimpleRadio from '@components/options/SimpleRadio';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { csvColumnsSwap } from './service';
|
||||
import { getCsvHeaders } from '@utils/csv';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
fromPositionStatus: true,
|
||||
toPositionStatus: true,
|
||||
fromPosition: '1',
|
||||
toPosition: '2',
|
||||
fromHeader: '',
|
||||
toHeader: '',
|
||||
emptyValuesFilling: true,
|
||||
customFiller: '',
|
||||
deleteComment: true,
|
||||
commentCharacter: '#',
|
||||
emptyLines: true
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Move the Key Column to the First Position',
|
||||
description:
|
||||
'In this example, we use our CSV column swapping tool to bring the most important information to the first column. As we are planning to go on vacation soon, in the input of the tool, we load data about national parks that include their names and locations. To decide, which is the closest park to us, we need to see the parks location first, therefore, we swap the first and second data columns so that the "location" column is at the beginning of the CSV data.',
|
||||
sampleText: `park_name,location
|
||||
Yellowstone,Wyoming
|
||||
Yosemite,California
|
||||
Grand Canyon,Arizona
|
||||
Rocky Mountain,Colorado
|
||||
Zion Park,Utah`,
|
||||
sampleResult: `location,park_name
|
||||
Wyoming,Yellowstone
|
||||
California,Yosemite
|
||||
Arizona,Grand Canyon
|
||||
Colorado,Rocky Mountain
|
||||
Utah,Zion Park`,
|
||||
sampleOptions: {
|
||||
fromPositionStatus: true,
|
||||
toPositionStatus: true,
|
||||
fromPosition: '1',
|
||||
toPosition: '2',
|
||||
fromHeader: 'park_name',
|
||||
toHeader: 'location',
|
||||
emptyValuesFilling: false,
|
||||
customFiller: '*',
|
||||
deleteComment: false,
|
||||
commentCharacter: '',
|
||||
emptyLines: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Reorganize Columns in Vitamins CSV',
|
||||
description:
|
||||
'In this example, a lab intern made a mistake and created a corrupted CSV file with mixed-up columns and missing data. To fix the file, we swap the columns based on the headers "Vitamin" and "Function" so that the "Vitamin" column becomes the first in the output data. We also fill the incomplete CSV data by adding a custom asterisk "*" symbol in place of missing values.',
|
||||
sampleText: `Function,Fat-Soluble,Vitamin,Sources
|
||||
Supports vision,Fat-Soluble,A,Carrots
|
||||
Immune function,Water-Soluble,C,Citrus fruits
|
||||
Bone health,Fat-Soluble,D,Fatty fish
|
||||
Antioxidant,Fat-Soluble,E,Nuts
|
||||
Blood clotting,Fat-Soluble,K,Leafy greens
|
||||
Energy production,Water-Soluble,B1
|
||||
Energy production,Water-Soluble,B2
|
||||
Energy production,Water-Soluble,B3,Meat
|
||||
Protein metabolism,Water-Soluble,B6,Poultry
|
||||
Nervous system,Water-Soluble,B12,Meat`,
|
||||
sampleResult: `Vitamin,Fat-Soluble,Function,Sources
|
||||
A,Fat-Soluble,Supports vision,Carrots
|
||||
C,Water-Soluble,Immune function,Citrus fruits
|
||||
D,Fat-Soluble,Bone health,Fatty fish
|
||||
E,Fat-Soluble,Antioxidant,Nuts
|
||||
K,Fat-Soluble,Blood clotting,Leafy greens
|
||||
B1,Water-Soluble,Energy production,*
|
||||
B2,Water-Soluble,Energy production,*
|
||||
B3,Water-Soluble,Energy production,Meat
|
||||
B6,Water-Soluble,Protein metabolism,Poultry
|
||||
B12,Water-Soluble,Nervous system,Meat`,
|
||||
sampleOptions: {
|
||||
fromPositionStatus: false,
|
||||
toPositionStatus: false,
|
||||
fromPosition: '1',
|
||||
toPosition: '2',
|
||||
fromHeader: 'Vitamin',
|
||||
toHeader: 'Function',
|
||||
emptyValuesFilling: false,
|
||||
customFiller: '*',
|
||||
deleteComment: false,
|
||||
commentCharacter: '',
|
||||
emptyLines: false
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Place Columns Side by Side for Analysis',
|
||||
description:
|
||||
'In this example, we change the order of columns in a CSV dataset to have the columns essential for analysis adjacent to each other. We match the "ScreenSize" column by its name and place it in the second-to-last position "-2". This groups the "ScreenSize" and "Price" columns together, allowing us to easily compare and choose the phone we want to buy. We also remove empty lines and specify that lines starting with the "#" symbol are comments and should be left as is.',
|
||||
sampleText: `Brand,Model,ScreenSize,OS,Price
|
||||
|
||||
Apple,iPhone 15 Pro Max,6.7″,iOS,$1299
|
||||
Samsung,Galaxy S23 Ultra,6.8″,Android,$1199
|
||||
Google,Pixel 7 Pro,6.4″,Android,$899
|
||||
|
||||
#OnePlus,11 Pro,6.7″,Android,$949
|
||||
Xiaomi,13 Ultra,6.6″,Android,$849`,
|
||||
sampleResult: `Brand,Model,OS,ScreenSize,Price
|
||||
Apple,iPhone 15 Pro Max,iOS,6.7″,$1299
|
||||
Samsung,Galaxy S23 Ultra,Android,6.8″,$1199
|
||||
Google,Pixel 7 Pro,Android,6.4″,$899
|
||||
Xiaomi,13 Ultra,Android,6.6″,$849`,
|
||||
sampleOptions: {
|
||||
fromPositionStatus: false,
|
||||
toPositionStatus: true,
|
||||
fromPosition: '1',
|
||||
toPosition: '4',
|
||||
fromHeader: 'ScreenSize',
|
||||
toHeader: 'OS',
|
||||
emptyValuesFilling: true,
|
||||
customFiller: 'x',
|
||||
deleteComment: true,
|
||||
commentCharacter: '#',
|
||||
emptyLines: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function CsvToTsv({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (optionsValues: InitialValuesType, input: string) => {
|
||||
setResult(csvColumnsSwap(input, optionsValues));
|
||||
};
|
||||
|
||||
const headers = getCsvHeaders(input);
|
||||
const headerOptions = headers.map((item) => ({
|
||||
label: `${item}`,
|
||||
value: item
|
||||
}));
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Swap-From Column',
|
||||
component: (
|
||||
<Box>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('fromPositionStatus', true)}
|
||||
title="Set Column-From position"
|
||||
checked={values.fromPositionStatus}
|
||||
/>
|
||||
{values.fromPositionStatus && (
|
||||
<TextFieldWithDesc
|
||||
description={'Position of the first column you want to swap'}
|
||||
value={values.fromPosition}
|
||||
onOwnChange={(val) => updateField('fromPosition', val)}
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: headers.length }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('fromPositionStatus', false)}
|
||||
title="Set Column-From Header"
|
||||
checked={!values.fromPositionStatus}
|
||||
/>
|
||||
{!values.fromPositionStatus && (
|
||||
<SelectWithDesc
|
||||
selected={values.fromHeader}
|
||||
options={headerOptions}
|
||||
onChange={(value) => updateField('fromHeader', value)}
|
||||
description={'Header of the first column you want to swap.'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Swap-to Column',
|
||||
component: (
|
||||
<Box>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('toPositionStatus', true)}
|
||||
title="Set Column-To position"
|
||||
checked={values.toPositionStatus}
|
||||
/>
|
||||
{values.toPositionStatus && (
|
||||
<TextFieldWithDesc
|
||||
description={'Position of the second column you want to swap'}
|
||||
value={values.toPosition}
|
||||
onOwnChange={(val) => updateField('toPosition', val)}
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: headers.length }}
|
||||
/>
|
||||
)}
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('toPositionStatus', false)}
|
||||
title="Set Column-To Header"
|
||||
checked={!values.toPositionStatus}
|
||||
/>
|
||||
{!values.toPositionStatus && (
|
||||
<SelectWithDesc
|
||||
selected={values.toHeader}
|
||||
options={headerOptions}
|
||||
onChange={(value) => updateField('toHeader', value)}
|
||||
description={'Header of the second column you want to swap..'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Incomplete Data',
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.emptyValuesFilling}
|
||||
options={[
|
||||
{ label: 'Fill With Empty Values', value: true },
|
||||
{ label: 'Fill with Custom Values', value: false }
|
||||
]}
|
||||
onChange={(value) => updateField('emptyValuesFilling', value)}
|
||||
description={
|
||||
'Fill incomplete CSV data with empty symbols or a custom symbol.'
|
||||
}
|
||||
/>
|
||||
{!values.emptyValuesFilling && (
|
||||
<TextFieldWithDesc
|
||||
description={
|
||||
'Specify a custom symbol to fill incomplete CSV data with'
|
||||
}
|
||||
value={values.customFiller}
|
||||
onOwnChange={(val) => updateField('customFiller', val)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Comments and Empty Lines',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.deleteComment}
|
||||
onChange={(value) => updateField('deleteComment', value)}
|
||||
title="Delete Comments"
|
||||
description="if checked, comments given by the following character will be deleted"
|
||||
/>
|
||||
{values.deleteComment && (
|
||||
<TextFieldWithDesc
|
||||
description={
|
||||
'Specify the character used to start comments in the input CSV (and if needed remove them via checkbox above)'
|
||||
}
|
||||
value={values.commentCharacter}
|
||||
onOwnChange={(val) => updateField('commentCharacter', val)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CheckboxWithDesc
|
||||
checked={values.emptyLines}
|
||||
onChange={(value) => updateField('emptyLines', value)}
|
||||
title="Delete Empty Lines"
|
||||
description="Do not include empty lines in the output data."
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
|
||||
resultComponent={<ToolTextResult value={result} />}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
setInput={setInput}
|
||||
compute={compute}
|
||||
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||
exampleCards={exampleCards}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/csv/swap-csv-columns/meta.ts
Normal file
15
src/pages/tools/csv/swap-csv-columns/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('csv', {
|
||||
name: 'Swap CSV Columns',
|
||||
path: 'swap-csv-columns',
|
||||
icon: 'eva:swap-outline',
|
||||
description:
|
||||
'Just upload your CSV file in the form below, specify the columns to swap, and the tool will automatically change the positions of the specified columns in the output file. In the tool options, you can specify the column positions or names that you want to swap, as well as fix incomplete data and optionally remove empty records and records that have been commented out.',
|
||||
shortDescription: 'Reorder CSV columns',
|
||||
longDescription:
|
||||
'This tool reorganizes CSV data by swapping the positions of its columns. Swapping columns can enhance the readability of a CSV file by placing frequently used data together or in the front for easier data comparison and editing. For example, you can swap the first column with the last or swap the second column with the third. To swap columns based on their positions, select the "Set Column Position" mode and enter the numbers of the "from" and "to" columns to be swapped in the first and second blocks of options. For example, if you have a CSV file with four columns "1, 2, 3, 4" and swap columns with positions "2" and "4", the output CSV will have columns in the order: "1, 4, 3, 2".As an alternative to positions, you can swap columns by specifying their headers (column names on the first row of data). If you enable this mode in the options, then you can enter the column names like "location" and "city", and the program will swap these two columns. If any of the specified columns have incomplete data (some fields are missing), you can choose to skip such data or fill the missing fields with empty values or custom values (specified in the options). Additionally, you can specify the symbol used for comments in the CSV data, such as "#" or "//". If you do not need the commented lines in the output, you can remove them by using the "Delete Comments" checkbox. You can also activate the checkbox "Delete Empty Lines" to get rid of empty lines that contain no visible information. Csv-abulous!',
|
||||
keywords: ['csv', 'swap', 'columns'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
61
src/pages/tools/csv/swap-csv-columns/service.ts
Normal file
61
src/pages/tools/csv/swap-csv-columns/service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { splitCsv } from '@utils/csv';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
function retrieveFromAndTo(
|
||||
headerRow: string[],
|
||||
options: InitialValuesType
|
||||
): number[] {
|
||||
const from = options.fromPositionStatus
|
||||
? Number(options.fromPosition)
|
||||
: headerRow.findIndex((header) => header === options.fromHeader) + 1;
|
||||
|
||||
const to = options.toPositionStatus
|
||||
? Number(options.toPosition)
|
||||
: headerRow.findIndex((header) => header === options.toHeader) + 1;
|
||||
|
||||
if (from <= 0 || to <= 0)
|
||||
throw new Error('Invalid column positions. Check headers or positions.');
|
||||
|
||||
if (from > headerRow.length || to > headerRow.length)
|
||||
throw new Error(`There are only ${headerRow.length} columns`);
|
||||
|
||||
return [from, to];
|
||||
}
|
||||
|
||||
function swap(lines: string[][], from: number, to: number): string[][] {
|
||||
if (from <= 0 || to <= 0)
|
||||
throw new Error('Columns position must be greater than zero ');
|
||||
|
||||
return lines.map((row) => {
|
||||
const newRow = [...row]; // Clone the row to avoid mutating the original
|
||||
[newRow[from - 1], newRow[to - 1]] = [newRow[to - 1], newRow[from - 1]]; // Swap values
|
||||
return newRow;
|
||||
});
|
||||
}
|
||||
|
||||
export function csvColumnsSwap(input: string, options: InitialValuesType) {
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
// split csv input and remove comments
|
||||
const rows = splitCsv(
|
||||
input,
|
||||
options.deleteComment,
|
||||
options.commentCharacter,
|
||||
options.emptyLines
|
||||
);
|
||||
|
||||
const columnCount = Math.max(...rows.map((row) => row.length));
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
for (let j = 0; j < columnCount; j++) {
|
||||
if (!rows[i][j]) {
|
||||
rows[i][j] = options.emptyValuesFilling ? '' : options.customFiller;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const positions = retrieveFromAndTo(rows[0], options);
|
||||
|
||||
const result = swap(rows, positions[0], positions[1]);
|
||||
return result.join('\n');
|
||||
}
|
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { InitialValuesType } from './types';
|
||||
import { csvColumnsSwap } from './service';
|
||||
|
||||
describe('csvColumnsSwap', () => {
|
||||
it('should swap columns by position', () => {
|
||||
const input = 'A,B,C\n1,2,3\n4,5,6';
|
||||
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '1', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
|
||||
});
|
||||
|
||||
it('should swap columns by header', () => {
|
||||
const input = 'A,B,C\n1,2,3\n4,5,6';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: false, // fromPositionStatus
|
||||
fromPosition: '', // fromPosition
|
||||
toPositionStatus: false, // toPositionStatus
|
||||
toPosition: '', // toPosition
|
||||
fromHeader: 'A', // fromHeader
|
||||
toHeader: 'C', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
|
||||
});
|
||||
|
||||
it('should fill missing values with custom filler', () => {
|
||||
const input = 'A,B,C\n1,2\n4';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '1', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: false, // dataCompletion
|
||||
customFiller: 'X', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('C,B,A\nX,2,1\nX,X,4');
|
||||
});
|
||||
|
||||
it('should skip filling missing values', () => {
|
||||
const input = 'A,B,C\n1,2\n4';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '1', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('C,B,A\n,2,1\n,,4');
|
||||
});
|
||||
|
||||
it('should throw an error for invalid column positions', () => {
|
||||
const input = 'A,B,C\n1,2,3\n4,5,6';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '0', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
expect(() => csvColumnsSwap(input, options)).toThrow(
|
||||
'Invalid column positions. Check headers or positions.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty input gracefully', () => {
|
||||
const input = '';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '1', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: false, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should remove comments if deleteComment is true', () => {
|
||||
const input = '# Comment\nA,B,C\n1,2,3\n4,5,6';
|
||||
const options: InitialValuesType = {
|
||||
fromPositionStatus: true, // fromPositionStatus
|
||||
fromPosition: '1', // fromPosition
|
||||
toPositionStatus: true, // toPositionStatus
|
||||
toPosition: '3', // toPosition
|
||||
fromHeader: '', // fromHeader
|
||||
toHeader: '', // toHeader
|
||||
emptyValuesFilling: true, // dataCompletion
|
||||
customFiller: '', // customFiller
|
||||
deleteComment: true, // deleteComment
|
||||
commentCharacter: '#', // commentCharacter
|
||||
emptyLines: true // emptyLines
|
||||
};
|
||||
const result = csvColumnsSwap(input, options);
|
||||
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
|
||||
});
|
||||
});
|
13
src/pages/tools/csv/swap-csv-columns/types.ts
Normal file
13
src/pages/tools/csv/swap-csv-columns/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type InitialValuesType = {
|
||||
fromPositionStatus: boolean;
|
||||
fromPosition: string | '';
|
||||
toPositionStatus: boolean;
|
||||
toPosition: string | '';
|
||||
fromHeader: string | '';
|
||||
toHeader: string | '';
|
||||
emptyValuesFilling: boolean;
|
||||
customFiller: string | '';
|
||||
deleteComment: boolean;
|
||||
commentCharacter: string | '';
|
||||
emptyLines: boolean;
|
||||
};
|
@@ -6,10 +6,10 @@ import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import ColorSelector from '@components/options/ColorSelector';
|
||||
import Color from 'color';
|
||||
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
|
||||
import { areColorsSimilar } from 'utils/color';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import { processImage } from './service';
|
||||
|
||||
const initialValues = {
|
||||
fromColor: 'white',
|
||||
@@ -19,7 +19,7 @@ const initialValues = {
|
||||
const validationSchema = Yup.object({
|
||||
// splitSeparator: Yup.string().required('The separator is required')
|
||||
});
|
||||
export default function ChangeColorsInPng({ title }: ToolComponentProps) {
|
||||
export default function ChangeColorsInImage({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
@@ -36,54 +36,10 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
const processImage = async (
|
||||
file: File,
|
||||
fromColor: [number, number, number],
|
||||
toColor: [number, number, number],
|
||||
similarity: number
|
||||
) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) return;
|
||||
const img = new Image();
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
await img.decode();
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data: Uint8ClampedArray = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const currentColor: [number, number, number] = [
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2]
|
||||
];
|
||||
if (areColorsSimilar(currentColor, fromColor, similarity)) {
|
||||
data[i] = toColor[0]; // Red
|
||||
data[i + 1] = toColor[1]; // Green
|
||||
data[i + 2] = toColor[2]; // Blue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, {
|
||||
type: 'image/png'
|
||||
});
|
||||
setResult(newFile);
|
||||
}
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
processImage(input, fromRgb, toRgb, Number(similarity));
|
||||
processImage(input, fromRgb, toRgb, Number(similarity), setResult);
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<typeof initialValues> = ({
|
||||
values,
|
||||
updateField
|
||||
@@ -127,22 +83,11 @@ export default function ChangeColorsInPng({ title }: ToolComponentProps) {
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png']}
|
||||
title={'Input PNG'}
|
||||
accept={['image/*']}
|
||||
title={'Input image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Transparent PNG'}
|
||||
value={result}
|
||||
extension={'png'}
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Make Colors Transparent',
|
||||
description:
|
||||
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
|
||||
}}
|
||||
resultComponent={<ToolFileResult title={'Result image'} value={result} />}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal file
13
src/pages/tools/image/generic/change-colors/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Change colors in image',
|
||||
path: 'change-colors',
|
||||
icon: 'cil:color-fill',
|
||||
description:
|
||||
"World's simplest online Image color changer. Just import your image (JPG, PNG, SVG) in the editor on the left, select which colors to change, and you'll instantly get a new image with the new colors on the right. Free, quick, and very powerful. Import an image – replace its colors.",
|
||||
shortDescription: 'Quickly swap colors in a image',
|
||||
keywords: ['change', 'colors', 'in', 'png', 'image', 'jpg'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
169
src/pages/tools/image/generic/change-colors/service.ts
Normal file
169
src/pages/tools/image/generic/change-colors/service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { areColorsSimilar } from '@utils/color';
|
||||
|
||||
export const processImage = async (
|
||||
file: File,
|
||||
fromColor: [number, number, number],
|
||||
toColor: [number, number, number],
|
||||
similarity: number,
|
||||
setResult: (result: File | null) => void
|
||||
): Promise<void> => {
|
||||
if (file.type === 'image/svg+xml') {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (!e.target?.result) return;
|
||||
|
||||
let svgContent = e.target.result as string;
|
||||
const toColorHex = rgbToHex(toColor[0], toColor[1], toColor[2]);
|
||||
|
||||
// Replace hex colors with various formats (#fff, #ffffff)
|
||||
const hexRegexShort = new RegExp(`#[0-9a-f]{3}\\b`, 'gi');
|
||||
const hexRegexLong = new RegExp(`#[0-9a-f]{6}\\b`, 'gi');
|
||||
|
||||
svgContent = svgContent.replace(hexRegexShort, (match) => {
|
||||
// Expand short hex to full form for comparison
|
||||
const expanded =
|
||||
'#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3];
|
||||
const matchRgb = hexToRgb(expanded);
|
||||
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
|
||||
return toColorHex;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
svgContent = svgContent.replace(hexRegexLong, (match) => {
|
||||
const matchRgb = hexToRgb(match);
|
||||
if (matchRgb && areColorsSimilar(matchRgb, fromColor, similarity)) {
|
||||
return toColorHex;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Replace RGB colors
|
||||
const rgbRegex = new RegExp(
|
||||
`rgb\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*\\)`,
|
||||
'gi'
|
||||
);
|
||||
svgContent = svgContent.replace(rgbRegex, (match, r, g, b) => {
|
||||
const matchRgb: [number, number, number] = [
|
||||
parseInt(r),
|
||||
parseInt(g),
|
||||
parseInt(b)
|
||||
];
|
||||
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
|
||||
return `rgb(${toColor[0]}, ${toColor[1]}, ${toColor[2]})`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Replace RGBA colors (preserving alpha)
|
||||
const rgbaRegex = new RegExp(
|
||||
`rgba\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*([\\d.]+)\\s*\\)`,
|
||||
'gi'
|
||||
);
|
||||
svgContent = svgContent.replace(rgbaRegex, (match, r, g, b, a) => {
|
||||
const matchRgb: [number, number, number] = [
|
||||
parseInt(r),
|
||||
parseInt(g),
|
||||
parseInt(b)
|
||||
];
|
||||
if (areColorsSimilar(matchRgb, fromColor, similarity)) {
|
||||
return `rgba(${toColor[0]}, ${toColor[1]}, ${toColor[2]}, ${a})`;
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
// Replace named SVG colors if they match our target color
|
||||
const namedColors = {
|
||||
red: [255, 0, 0],
|
||||
green: [0, 128, 0],
|
||||
blue: [0, 0, 255],
|
||||
black: [0, 0, 0],
|
||||
white: [255, 255, 255]
|
||||
// Add more named colors as needed
|
||||
};
|
||||
|
||||
Object.entries(namedColors).forEach(([name, rgb]) => {
|
||||
if (
|
||||
areColorsSimilar(
|
||||
rgb as [number, number, number],
|
||||
fromColor,
|
||||
similarity
|
||||
)
|
||||
) {
|
||||
const colorRegex = new RegExp(`\\b${name}\\b`, 'gi');
|
||||
svgContent = svgContent.replace(colorRegex, toColorHex);
|
||||
}
|
||||
});
|
||||
|
||||
// Create new file with modified content
|
||||
const newFile = new File([svgContent], file.name, {
|
||||
type: 'image/svg+xml'
|
||||
});
|
||||
setResult(newFile);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) return;
|
||||
const img = new Image();
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
await img.decode();
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data: Uint8ClampedArray = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const currentColor: [number, number, number] = [
|
||||
data[i],
|
||||
data[i + 1],
|
||||
data[i + 2]
|
||||
];
|
||||
if (areColorsSimilar(currentColor, fromColor, similarity)) {
|
||||
data[i] = toColor[0]; // Red
|
||||
data[i + 1] = toColor[1]; // Green
|
||||
data[i + 2] = toColor[2]; // Blue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, {
|
||||
type: file.type
|
||||
});
|
||||
setResult(newFile);
|
||||
}
|
||||
}, file.type);
|
||||
};
|
||||
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((x) => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
})
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to parse hex to RGB
|
||||
const hexToRgb = (hex: string): [number, number, number] | null => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? [
|
||||
parseInt(result[1], 16),
|
||||
parseInt(result[2], 16),
|
||||
parseInt(result[3], 16)
|
||||
]
|
||||
: null;
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import { changeOpacity } from './service';
|
||||
@@ -97,16 +97,12 @@ export default function ChangeOpacity({ title }: ToolComponentProps) {
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png']}
|
||||
title={'Input PNG'}
|
||||
accept={['image/*']}
|
||||
title={'Input image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Changed PNG'}
|
||||
value={result}
|
||||
extension={'png'}
|
||||
/>
|
||||
<ToolFileResult title={'Changed image'} value={result} />
|
||||
}
|
||||
initialValues={initialValues}
|
||||
// exampleCards={exampleCards}
|
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal file
13
src/pages/tools/image/generic/change-opacity/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Change image Opacity',
|
||||
path: 'change-opacity',
|
||||
icon: 'material-symbols:opacity',
|
||||
description:
|
||||
'Easily adjust the transparency of your images. Simply upload your image, use the slider to set the desired opacity level between 0 (fully transparent) and 1 (fully opaque), and download the modified image.',
|
||||
shortDescription: 'Adjust transparency of images',
|
||||
keywords: ['opacity', 'transparency', 'png', 'alpha', 'jpg', 'jpeg', 'image'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
@@ -9,7 +9,10 @@ interface OpacityOptions {
|
||||
areaHeight: number;
|
||||
}
|
||||
|
||||
export async function changeOpacity(file: File, options: OpacityOptions): Promise<File> {
|
||||
export async function changeOpacity(
|
||||
file: File,
|
||||
options: OpacityOptions
|
||||
): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
@@ -32,12 +35,12 @@ export async function changeOpacity(file: File, options: OpacityOptions): Promis
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, { type: 'image/png' });
|
||||
const newFile = new File([blob], file.name, { type: file.type });
|
||||
resolve(newFile);
|
||||
} else {
|
||||
reject(new Error('Failed to generate image blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
}, file.type);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Failed to load image'));
|
||||
img.src = event.target?.result as string;
|
||||
@@ -67,9 +70,10 @@ function applyGradientOpacity(
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
const gradient = options.gradientType === 'linear'
|
||||
? createLinearGradient(ctx, options)
|
||||
: createRadialGradient(ctx, options);
|
||||
const gradient =
|
||||
options.gradientType === 'linear'
|
||||
? createLinearGradient(ctx, options)
|
||||
: createRadialGradient(ctx, options);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(areaLeft, areaTop, areaWidth, areaHeight);
|
121
src/pages/tools/image/generic/compress/index.tsx
Normal file
121
src/pages/tools/image/generic/compress/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { InitialValuesType } from './types';
|
||||
import { compressImage } from './service';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { Box } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
|
||||
import { updateNumberField } from '@utils/string';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
maxFileSizeInMB: 1.0,
|
||||
quality: 80
|
||||
};
|
||||
|
||||
export default function CompressImage({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = 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;
|
||||
|
||||
setOriginalSize(input.size);
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
const compressed = await compressImage(input, values);
|
||||
|
||||
if (compressed) {
|
||||
setResult(compressed);
|
||||
setCompressedSize(compressed.size);
|
||||
} else {
|
||||
showSnackBar('Failed to compress image. Please try again.', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in compression:', err);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/*']}
|
||||
title={'Input image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Compressed image'}
|
||||
value={result}
|
||||
loading={isProcessing}
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: 'Compression options',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
name="maxFileSizeInMB"
|
||||
type="number"
|
||||
inputProps={{ min: 0.1, step: 0.1 }}
|
||||
description="Maximum file size in megabytes"
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'maxFileSizeInMB', updateField)
|
||||
}
|
||||
value={values.maxFileSizeInMB}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
name="quality"
|
||||
type="number"
|
||||
inputProps={{ min: 10, max: 100, step: 1 }}
|
||||
description="Image quality percentage (lower means smaller file size)"
|
||||
onOwnChange={(value) =>
|
||||
updateNumberField(value, 'quality', updateField)
|
||||
}
|
||||
value={values.quality}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'File sizes',
|
||||
component: (
|
||||
<Box>
|
||||
<Box>
|
||||
{originalSize !== null && (
|
||||
<Typography>
|
||||
Original Size: {(originalSize / 1024).toFixed(2)} KB
|
||||
</Typography>
|
||||
)}
|
||||
{compressedSize !== null && (
|
||||
<Typography>
|
||||
Compressed Size: {(compressedSize / 1024).toFixed(2)} KB
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
compute={compute}
|
||||
setInput={setInput}
|
||||
/>
|
||||
);
|
||||
}
|
14
src/pages/tools/image/generic/compress/meta.ts
Normal file
14
src/pages/tools/image/generic/compress/meta.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Compress Image',
|
||||
path: 'compress',
|
||||
component: lazy(() => import('./index')),
|
||||
icon: 'material-symbols-light:compress-rounded',
|
||||
description:
|
||||
'Compress images to reduce file size while maintaining reasonable quality.',
|
||||
shortDescription:
|
||||
'Compress images to reduce file size while maintaining reasonable quality.',
|
||||
keywords: ['image', 'compress', 'reduce', 'quality']
|
||||
});
|
30
src/pages/tools/image/generic/compress/service.ts
Normal file
30
src/pages/tools/image/generic/compress/service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
|
||||
export const compressImage = async (
|
||||
file: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File | null> => {
|
||||
try {
|
||||
const { maxFileSizeInMB, quality } = options;
|
||||
|
||||
// Configuration for the compression library
|
||||
const compressionOptions = {
|
||||
maxSizeMB: maxFileSizeInMB,
|
||||
maxWidthOrHeight: 1920, // Reasonable default for most use cases
|
||||
useWebWorker: true,
|
||||
initialQuality: quality / 100 // Convert percentage to decimal
|
||||
};
|
||||
|
||||
// Compress the image
|
||||
const compressedFile = await imageCompression(file, compressionOptions);
|
||||
|
||||
// Create a new file with the original name
|
||||
return new File([compressedFile], file.name, {
|
||||
type: compressedFile.type
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error compressing image:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
4
src/pages/tools/image/generic/compress/types.ts
Normal file
4
src/pages/tools/image/generic/compress/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface InitialValuesType {
|
||||
maxFileSizeInMB: number;
|
||||
quality: number;
|
||||
}
|
@@ -5,7 +5,7 @@ import Jimp from 'jimp';
|
||||
|
||||
test.describe('Create transparent PNG', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/png/create-transparent');
|
||||
await page.goto('/image-generic/create-transparent');
|
||||
});
|
||||
|
||||
//TODO check why failing
|
@@ -112,8 +112,8 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png']}
|
||||
title={'Input PNG'}
|
||||
accept={['image/*']}
|
||||
title={'Input image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
@@ -131,7 +131,7 @@ export default function CreateTransparent({ title }: ToolComponentProps) {
|
||||
toolInfo={{
|
||||
title: 'Create Transparent PNG',
|
||||
description:
|
||||
'This tool allows you to make specific colors in a PNG image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
|
||||
'This tool allows you to make specific colors in an image transparent. You can select the color to replace and adjust the similarity threshold to include similar colors.'
|
||||
}}
|
||||
/>
|
||||
);
|
@@ -1,13 +1,13 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Create transparent PNG',
|
||||
path: 'create-transparent',
|
||||
icon: 'mdi:circle-transparent',
|
||||
shortDescription: 'Quickly make a PNG image transparent',
|
||||
shortDescription: 'Quickly make an image transparent',
|
||||
description:
|
||||
"World's simplest online Portable Network Graphics transparency maker. Just import your PNG image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import a PNG – get a transparent PNG.",
|
||||
"World's simplest online Portable Network Graphics transparency maker. Just import your image in the editor on the left and you will instantly get a transparent PNG on the right. Free, quick, and very powerful. Import an image – get a transparent PNG.",
|
||||
keywords: ['create', 'transparent'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -32,7 +32,7 @@ const validationSchema = Yup.object({
|
||||
.required('Height is required')
|
||||
});
|
||||
|
||||
export default function CropPng({ title }: ToolComponentProps) {
|
||||
export default function CropImage({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
@@ -101,11 +101,11 @@ export default function CropPng({ title }: ToolComponentProps) {
|
||||
destCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const newFile = new File([blob], file.name, {
|
||||
type: 'image/png'
|
||||
type: file.type
|
||||
});
|
||||
setResult(newFile);
|
||||
}
|
||||
}, 'image/png');
|
||||
}, file.type);
|
||||
};
|
||||
|
||||
processImage(input, x, y, width, height, isCircular);
|
||||
@@ -180,13 +180,13 @@ export default function CropPng({ title }: ToolComponentProps) {
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('cropShape', 'rectangular')}
|
||||
checked={values.cropShape == 'rectangular'}
|
||||
description={'Crop a rectangular fragment from a PNG.'}
|
||||
description={'Crop a rectangular fragment from an image.'}
|
||||
title={'Rectangular Crop Shape'}
|
||||
/>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('cropShape', 'circular')}
|
||||
checked={values.cropShape == 'circular'}
|
||||
description={'Crop a circular fragment from a PNG.'}
|
||||
description={'Crop a circular fragment from an image.'}
|
||||
title={'Circular Crop Shape'}
|
||||
/>
|
||||
</Box>
|
||||
@@ -200,8 +200,8 @@ export default function CropPng({ title }: ToolComponentProps) {
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png']}
|
||||
title={'Input PNG'}
|
||||
accept={['image/*']}
|
||||
title={'Input image'}
|
||||
showCropOverlay={!!input}
|
||||
cropShape={values.cropShape as 'rectangular' | 'circular'}
|
||||
cropPosition={{
|
||||
@@ -225,16 +225,12 @@ export default function CropPng({ title }: ToolComponentProps) {
|
||||
validationSchema={validationSchema}
|
||||
renderCustomInput={renderCustomInput}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Cropped PNG'}
|
||||
value={result}
|
||||
extension={'png'}
|
||||
/>
|
||||
<ToolFileResult title={'Cropped image'} value={result} />
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Crop PNG Image',
|
||||
title: 'Crop Image',
|
||||
description:
|
||||
'This tool allows you to crop a PNG image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
|
||||
'This tool allows you to crop an image by specifying the position, size, and shape of the crop area. You can choose between rectangular or circular cropping.'
|
||||
}}
|
||||
/>
|
||||
);
|
@@ -1,7 +1,7 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Crop',
|
||||
path: 'crop',
|
||||
icon: 'mdi:crop', // Iconify icon as a string
|
108
src/pages/tools/image/generic/image-to-text/index.tsx
Normal file
108
src/pages/tools/image/generic/image-to-text/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import ToolTextResult from '@components/result/ToolTextResult';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { extractTextFromImage, getAvailableLanguages } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
import { CustomSnackBarContext } from '../../../../../contexts/CustomSnackBarContext';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
language: 'eng',
|
||||
detectParagraphs: true
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
language: Yup.string().required('Language is required')
|
||||
});
|
||||
|
||||
export default function ImageToText({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<string>('');
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const compute = async (optionsValues: InitialValuesType, input: any) => {
|
||||
if (!input) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const extractedText = await extractTextFromImage(input, optionsValues);
|
||||
setResult(extractedText);
|
||||
} catch (err: any) {
|
||||
showSnackBar(
|
||||
err.message || 'An error occurred while processing the image',
|
||||
'error'
|
||||
);
|
||||
setResult('');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'OCR Options',
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.language}
|
||||
onChange={(val) => updateField('language', val)}
|
||||
description={
|
||||
'Select the primary language in the image for better accuracy'
|
||||
}
|
||||
options={getAvailableLanguages()}
|
||||
/>
|
||||
<CheckboxWithDesc
|
||||
checked={values.detectParagraphs}
|
||||
onChange={(value) => updateField('detectParagraphs', value)}
|
||||
description={
|
||||
'Attempt to preserve paragraph structure in the extracted text'
|
||||
}
|
||||
title={'Detect Paragraphs'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={compute}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
inputComponent={
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/jpeg', 'image/png']}
|
||||
title={'Input Image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolTextResult
|
||||
title={'Extracted Text'}
|
||||
value={result}
|
||||
loading={isProcessing}
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Image to Text (OCR)',
|
||||
description:
|
||||
'This tool extracts text from images using Optical Character Recognition (OCR). Upload an image containing text, select the primary language, and get the extracted text. For best results, use clear images with good contrast.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/pages/tools/image/generic/image-to-text/meta.ts
Normal file
22
src/pages/tools/image/generic/image-to-text/meta.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Image to Text (OCR)',
|
||||
path: 'image-to-text',
|
||||
icon: 'mdi:text-recognition', // Iconify icon as a string
|
||||
description:
|
||||
'Extract text from images (JPG, PNG) using optical character recognition (OCR).',
|
||||
shortDescription: 'Extract text from images using OCR.',
|
||||
keywords: [
|
||||
'ocr',
|
||||
'optical character recognition',
|
||||
'image to text',
|
||||
'extract text',
|
||||
'scan',
|
||||
'tesseract',
|
||||
'jpg',
|
||||
'png'
|
||||
],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
56
src/pages/tools/image/generic/image-to-text/service.ts
Normal file
56
src/pages/tools/image/generic/image-to-text/service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createWorker } from 'tesseract.js';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
export const extractTextFromImage = async (
|
||||
file: File,
|
||||
options: InitialValuesType
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const { language, detectParagraphs } = options;
|
||||
|
||||
// Create a Tesseract worker
|
||||
const worker = await createWorker(language);
|
||||
|
||||
// Convert file to URL
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
|
||||
// Recognize text
|
||||
const { data } = await worker.recognize(imageUrl);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
await worker.terminate();
|
||||
|
||||
// Process the result based on options
|
||||
if (detectParagraphs) {
|
||||
// Return text with paragraph structure preserved
|
||||
return data.text;
|
||||
} else {
|
||||
// Return plain text with basic formatting
|
||||
return data.text;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting text from image:', error);
|
||||
throw new Error(
|
||||
'Failed to extract text from image. Please try again with a clearer image.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get available languages
|
||||
export const getAvailableLanguages = (): { value: string; label: string }[] => {
|
||||
return [
|
||||
{ value: 'eng', label: 'English' },
|
||||
{ value: 'fra', label: 'French' },
|
||||
{ value: 'deu', label: 'German' },
|
||||
{ value: 'spa', label: 'Spanish' },
|
||||
{ value: 'ita', label: 'Italian' },
|
||||
{ value: 'por', label: 'Portuguese' },
|
||||
{ value: 'rus', label: 'Russian' },
|
||||
{ value: 'jpn', label: 'Japanese' },
|
||||
{ value: 'chi_sim', label: 'Chinese (Simplified)' },
|
||||
{ value: 'chi_tra', label: 'Chinese (Traditional)' },
|
||||
{ value: 'kor', label: 'Korean' },
|
||||
{ value: 'ara', label: 'Arabic' }
|
||||
];
|
||||
};
|
4
src/pages/tools/image/generic/image-to-text/types.ts
Normal file
4
src/pages/tools/image/generic/image-to-text/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type InitialValuesType = {
|
||||
language: string;
|
||||
detectParagraphs: boolean;
|
||||
};
|
19
src/pages/tools/image/generic/index.ts
Normal file
19
src/pages/tools/image/generic/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { tool as resizeImage } from './resize/meta';
|
||||
import { tool as compressImage } from './compress/meta';
|
||||
import { tool as changeColors } from './change-colors/meta';
|
||||
import { tool as removeBackground } from './remove-background/meta';
|
||||
import { tool as cropImage } from './crop/meta';
|
||||
import { tool as changeOpacity } from './change-opacity/meta';
|
||||
import { tool as createTransparent } from './create-transparent/meta';
|
||||
import { tool as imageToText } from './image-to-text/meta';
|
||||
|
||||
export const imageGenericTools = [
|
||||
resizeImage,
|
||||
compressImage,
|
||||
removeBackground,
|
||||
cropImage,
|
||||
changeOpacity,
|
||||
changeColors,
|
||||
createTransparent,
|
||||
imageToText
|
||||
];
|
@@ -1,4 +1,3 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
@@ -11,7 +10,9 @@ const initialValues = {};
|
||||
|
||||
const validationSchema = Yup.object({});
|
||||
|
||||
export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
|
||||
export default function RemoveBackgroundFromImage({
|
||||
title
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
@@ -64,7 +65,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/png', 'image/jpeg', 'image/jpg']}
|
||||
accept={['image/*']}
|
||||
title={'Input Image'}
|
||||
/>
|
||||
}
|
||||
@@ -78,7 +79,7 @@ export default function RemoveBackgroundFromPng({ title }: ToolComponentProps) {
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Remove Background from PNG',
|
||||
title: 'Remove Background from Image',
|
||||
description:
|
||||
'This tool uses AI to automatically remove the background from your images, creating a transparent PNG. Perfect for product photos, profile pictures, and design assets.'
|
||||
}}
|
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal file
21
src/pages/tools/image/generic/remove-background/meta.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Remove Background from Image',
|
||||
path: 'remove-background',
|
||||
icon: 'mdi:image-remove',
|
||||
description:
|
||||
"World's simplest online tool to remove backgrounds from images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
|
||||
shortDescription: 'Automatically remove backgrounds from images',
|
||||
keywords: [
|
||||
'remove',
|
||||
'background',
|
||||
'png',
|
||||
'transparent',
|
||||
'image',
|
||||
'ai',
|
||||
'jpg'
|
||||
],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
203
src/pages/tools/image/generic/resize/index.tsx
Normal file
203
src/pages/tools/image/generic/resize/index.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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 { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
resizeMethod: 'pixels' as 'pixels' | 'percentage',
|
||||
dimensionType: 'width' as 'width' | 'height',
|
||||
width: '800',
|
||||
height: '600',
|
||||
percentage: '50',
|
||||
maintainAspectRatio: true
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
width: Yup.number().when('resizeMethod', {
|
||||
is: 'pixels',
|
||||
then: (schema) =>
|
||||
schema.min(1, 'Width must be at least 1px').required('Width is required')
|
||||
}),
|
||||
height: Yup.number().when('resizeMethod', {
|
||||
is: 'pixels',
|
||||
then: (schema) =>
|
||||
schema
|
||||
.min(1, 'Height must be at least 1px')
|
||||
.required('Height is required')
|
||||
}),
|
||||
percentage: Yup.number().when('resizeMethod', {
|
||||
is: 'percentage',
|
||||
then: (schema) =>
|
||||
schema
|
||||
.min(1, 'Percentage must be at least 1%')
|
||||
.max(1000, 'Percentage must be at most 1000%')
|
||||
.required('Percentage is required')
|
||||
})
|
||||
});
|
||||
|
||||
export default function ResizeImage({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
|
||||
const compute = async (optionsValues: InitialValuesType, input: any) => {
|
||||
if (!input) return;
|
||||
setResult(await processImage(input, optionsValues));
|
||||
};
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Resize Method',
|
||||
component: (
|
||||
<Box>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('resizeMethod', 'pixels')}
|
||||
checked={values.resizeMethod === 'pixels'}
|
||||
description={'Resize by specifying dimensions in pixels.'}
|
||||
title={'Resize by Pixels'}
|
||||
/>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('resizeMethod', 'percentage')}
|
||||
checked={values.resizeMethod === 'percentage'}
|
||||
description={
|
||||
'Resize by specifying a percentage of the original size.'
|
||||
}
|
||||
title={'Resize by Percentage'}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
...(values.resizeMethod === 'pixels'
|
||||
? [
|
||||
{
|
||||
title: 'Dimension Type',
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.maintainAspectRatio}
|
||||
onChange={(value) =>
|
||||
updateField('maintainAspectRatio', value)
|
||||
}
|
||||
description={
|
||||
'Maintain the original aspect ratio of the image.'
|
||||
}
|
||||
title={'Maintain Aspect Ratio'}
|
||||
/>
|
||||
{values.maintainAspectRatio && (
|
||||
<Box>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('dimensionType', 'width')}
|
||||
checked={values.dimensionType === 'width'}
|
||||
description={
|
||||
'Specify the width in pixels and calculate height based on aspect ratio.'
|
||||
}
|
||||
title={'Set Width'}
|
||||
/>
|
||||
<SimpleRadio
|
||||
onClick={() => updateField('dimensionType', 'height')}
|
||||
checked={values.dimensionType === 'height'}
|
||||
description={
|
||||
'Specify the height in pixels and calculate width based on aspect ratio.'
|
||||
}
|
||||
title={'Set Height'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<TextFieldWithDesc
|
||||
value={values.width}
|
||||
onOwnChange={(val) => updateField('width', val)}
|
||||
description={'Width (in pixels)'}
|
||||
disabled={
|
||||
values.maintainAspectRatio &&
|
||||
values.dimensionType === 'height'
|
||||
}
|
||||
inputProps={{
|
||||
'data-testid': 'width-input',
|
||||
type: 'number',
|
||||
min: 1
|
||||
}}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.height}
|
||||
onOwnChange={(val) => updateField('height', val)}
|
||||
description={'Height (in pixels)'}
|
||||
disabled={
|
||||
values.maintainAspectRatio &&
|
||||
values.dimensionType === 'width'
|
||||
}
|
||||
inputProps={{
|
||||
'data-testid': 'height-input',
|
||||
type: 'number',
|
||||
min: 1
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: 'Percentage',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.percentage}
|
||||
onOwnChange={(val) => updateField('percentage', val)}
|
||||
description={
|
||||
'Percentage of original size (e.g., 50 for half size, 200 for double size)'
|
||||
}
|
||||
inputProps={{
|
||||
'data-testid': 'percentage-input',
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 1000
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={compute}
|
||||
input={input}
|
||||
validationSchema={validationSchema}
|
||||
inputComponent={
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/jpeg', 'image/png', 'image/svg+xml', 'image/gif']}
|
||||
title={'Input Image'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Resized Image'}
|
||||
value={result}
|
||||
extension={input?.name.split('.').pop() || 'png'}
|
||||
/>
|
||||
}
|
||||
toolInfo={{
|
||||
title: 'Resize Image',
|
||||
description:
|
||||
'This tool allows you to resize JPG, PNG, SVG, or GIF images. You can resize by specifying dimensions in pixels or by percentage, with options to maintain the original aspect ratio.'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/pages/tools/image/generic/resize/meta.ts
Normal file
22
src/pages/tools/image/generic/resize/meta.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('image-generic', {
|
||||
name: 'Resize Image',
|
||||
path: 'resize',
|
||||
icon: 'mdi:resize', // Iconify icon as a string
|
||||
description:
|
||||
'Resize JPG, PNG, SVG or GIF images by pixels or percentage while maintaining aspect ratio or not.',
|
||||
shortDescription: 'Resize images easily.',
|
||||
keywords: [
|
||||
'resize',
|
||||
'image',
|
||||
'scale',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'gif',
|
||||
'dimensions'
|
||||
],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
218
src/pages/tools/image/generic/resize/service.ts
Normal file
218
src/pages/tools/image/generic/resize/service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile, toBlobURL } from '@ffmpeg/util';
|
||||
|
||||
export const processImage = async (
|
||||
file: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File | null> => {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
resizeMethod,
|
||||
percentage,
|
||||
dimensionType,
|
||||
maintainAspectRatio
|
||||
} = options;
|
||||
if (file.type === 'image/svg+xml') {
|
||||
try {
|
||||
// Read the SVG file
|
||||
const fileText = await file.text();
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(fileText, 'image/svg+xml');
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
// Get original dimensions
|
||||
const viewBox = svgElement.getAttribute('viewBox');
|
||||
let originalWidth: string | number | null =
|
||||
svgElement.getAttribute('width');
|
||||
let originalHeight: string | number | null =
|
||||
svgElement.getAttribute('height');
|
||||
|
||||
// Parse viewBox if available and width/height are not explicitly set
|
||||
let viewBoxValues = null;
|
||||
if (viewBox) {
|
||||
viewBoxValues = viewBox.split(' ').map(Number);
|
||||
}
|
||||
|
||||
// Determine original dimensions from viewBox if not explicitly set
|
||||
if (!originalWidth && viewBoxValues && viewBoxValues.length === 4) {
|
||||
originalWidth = String(viewBoxValues[2]);
|
||||
}
|
||||
if (!originalHeight && viewBoxValues && viewBoxValues.length === 4) {
|
||||
originalHeight = String(viewBoxValues[3]);
|
||||
}
|
||||
|
||||
// Default dimensions if still not available
|
||||
originalWidth = originalWidth ? parseFloat(originalWidth) : 300;
|
||||
originalHeight = originalHeight ? parseFloat(originalHeight) : 150;
|
||||
|
||||
// Calculate new dimensions
|
||||
let newWidth = originalWidth;
|
||||
let newHeight = originalHeight;
|
||||
|
||||
if (resizeMethod === 'pixels') {
|
||||
if (dimensionType === 'width') {
|
||||
newWidth = parseInt(width);
|
||||
if (maintainAspectRatio) {
|
||||
newHeight = Math.round((newWidth / originalWidth) * originalHeight);
|
||||
} else {
|
||||
newHeight = parseInt(height);
|
||||
}
|
||||
} else {
|
||||
// height
|
||||
newHeight = parseInt(height);
|
||||
if (maintainAspectRatio) {
|
||||
newWidth = Math.round((newHeight / originalHeight) * originalWidth);
|
||||
} else {
|
||||
newWidth = parseInt(width);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// percentage
|
||||
const scale = parseInt(percentage) / 100;
|
||||
newWidth = Math.round(originalWidth * scale);
|
||||
newHeight = Math.round(originalHeight * scale);
|
||||
}
|
||||
|
||||
// Update SVG attributes
|
||||
svgElement.setAttribute('width', String(newWidth));
|
||||
svgElement.setAttribute('height', String(newHeight));
|
||||
|
||||
// If viewBox isn't already set, add it to preserve scaling
|
||||
if (!viewBox) {
|
||||
svgElement.setAttribute(
|
||||
'viewBox',
|
||||
`0 0 ${originalWidth} ${originalHeight}`
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize the modified SVG document
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgDoc);
|
||||
|
||||
// Create a new file
|
||||
return new File([svgString], file.name, {
|
||||
type: 'image/svg+xml'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing SVG:', error);
|
||||
// Fall back to canvas method if SVG processing fails
|
||||
}
|
||||
} else if (file.type === 'image/gif') {
|
||||
try {
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
|
||||
// Write the input file to memory
|
||||
await ffmpeg.writeFile('input.gif', await fetchFile(file));
|
||||
|
||||
// Calculate new dimensions
|
||||
let newWidth = 0;
|
||||
let newHeight = 0;
|
||||
let scaleFilter = '';
|
||||
|
||||
if (resizeMethod === 'pixels') {
|
||||
if (dimensionType === 'width') {
|
||||
newWidth = parseInt(width);
|
||||
if (maintainAspectRatio) {
|
||||
scaleFilter = `scale=${newWidth}:-1`;
|
||||
} else {
|
||||
newHeight = parseInt(height);
|
||||
scaleFilter = `scale=${newWidth}:${newHeight}`;
|
||||
}
|
||||
} else {
|
||||
// height
|
||||
newHeight = parseInt(height);
|
||||
if (maintainAspectRatio) {
|
||||
scaleFilter = `scale=-1:${newHeight}`;
|
||||
} else {
|
||||
newWidth = parseInt(width);
|
||||
scaleFilter = `scale=${newWidth}:${newHeight}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// percentage
|
||||
const scale = parseInt(percentage) / 100;
|
||||
scaleFilter = `scale=iw*${scale}:ih*${scale}`;
|
||||
}
|
||||
|
||||
// Run FFmpeg command
|
||||
await ffmpeg.exec(['-i', 'input.gif', '-vf', scaleFilter, 'output.gif']);
|
||||
|
||||
// Read the output file
|
||||
const data = await ffmpeg.readFile('output.gif');
|
||||
|
||||
// Create a new File object
|
||||
return new File([data], file.name, { type: 'image/gif' });
|
||||
} catch (error) {
|
||||
console.error('Error processing GIF with FFmpeg:', error);
|
||||
// Fall back to canvas method if FFmpeg processing fails
|
||||
}
|
||||
}
|
||||
// Create canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx == null) return null;
|
||||
|
||||
// Load image
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
await img.decode();
|
||||
|
||||
// Calculate new dimensions
|
||||
let newWidth = img.width;
|
||||
let newHeight = img.height;
|
||||
|
||||
if (resizeMethod === 'pixels') {
|
||||
if (dimensionType === 'width') {
|
||||
newWidth = parseInt(width);
|
||||
if (maintainAspectRatio) {
|
||||
newHeight = Math.round((newWidth / img.width) * img.height);
|
||||
} else {
|
||||
newHeight = parseInt(height);
|
||||
}
|
||||
} else {
|
||||
// height
|
||||
newHeight = parseInt(height);
|
||||
if (maintainAspectRatio) {
|
||||
newWidth = Math.round((newHeight / img.height) * img.width);
|
||||
} else {
|
||||
newWidth = parseInt(width);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// percentage
|
||||
const scale = parseInt(percentage) / 100;
|
||||
newWidth = Math.round(img.width * scale);
|
||||
newHeight = Math.round(img.height * scale);
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// Draw resized image
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// Determine output type based on input file
|
||||
let outputType = 'image/png';
|
||||
if (file.type) {
|
||||
outputType = file.type;
|
||||
}
|
||||
|
||||
// Convert canvas to blob and create file
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(new File([blob], file.name, { type: outputType }));
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, outputType);
|
||||
});
|
||||
};
|
8
src/pages/tools/image/generic/resize/types.ts
Normal file
8
src/pages/tools/image/generic/resize/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type InitialValuesType = {
|
||||
resizeMethod: 'pixels' | 'percentage';
|
||||
dimensionType: 'width' | 'height';
|
||||
width: string;
|
||||
height: string;
|
||||
percentage: string;
|
||||
maintainAspectRatio: boolean;
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import { pngTools } from './png';
|
||||
import { imageGenericTools } from './generic';
|
||||
|
||||
export const imageTools = [...pngTools];
|
||||
export const imageTools = [...imageGenericTools, ...pngTools];
|
||||
|
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { Buffer } from 'buffer';
|
||||
import path from 'path';
|
||||
import Jimp from 'jimp';
|
||||
import { convertHexToRGBA } from '@utils/color';
|
||||
|
||||
test.describe('Change colors in png', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/png/change-colors-in-png');
|
||||
});
|
||||
|
||||
// test('should change pixel color', async ({ page }) => {
|
||||
// // Upload image
|
||||
// const fileInput = page.locator('input[type="file"]');
|
||||
// const imagePath = path.join(__dirname, 'test.png');
|
||||
// await fileInput?.setInputFiles(imagePath);
|
||||
//
|
||||
// await page.getByTestId('from-color-input').fill('#FF0000');
|
||||
// const toColor = '#0000FF';
|
||||
// await page.getByTestId('to-color-input').fill(toColor);
|
||||
//
|
||||
// // Click on download
|
||||
// const downloadPromise = page.waitForEvent('download');
|
||||
// await page.getByText('Save as').click();
|
||||
//
|
||||
// // Intercept and read downloaded PNG
|
||||
// const download = await downloadPromise;
|
||||
// const downloadStream = await download.createReadStream();
|
||||
//
|
||||
// const chunks = [];
|
||||
// for await (const chunk of downloadStream) {
|
||||
// chunks.push(chunk);
|
||||
// }
|
||||
// const fileContent = Buffer.concat(chunks);
|
||||
//
|
||||
// expect(fileContent.length).toBeGreaterThan(0);
|
||||
//
|
||||
// // Check that the first pixel is transparent
|
||||
// const image = await Jimp.read(fileContent);
|
||||
// const color = image.getPixelColor(0, 0);
|
||||
// expect(color).toBe(convertHexToRGBA(toColor));
|
||||
// });
|
||||
});
|
@@ -1,13 +0,0 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
name: 'Change colors in png',
|
||||
path: 'change-colors-in-png',
|
||||
icon: 'cil:color-fill',
|
||||
description:
|
||||
"World's simplest online Portable Network Graphics (PNG) color changer. Just import your PNG image in the editor on the left, select which colors to change, and you'll instantly get a new PNG with the new colors on the right. Free, quick, and very powerful. Import a PNG – replace its colors.",
|
||||
shortDescription: 'Quickly swap colors in a PNG image',
|
||||
keywords: ['change', 'colors', 'in', 'png'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
@@ -1,12 +0,0 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
name: 'Change PNG Opacity',
|
||||
path: 'change-opacity',
|
||||
icon: 'material-symbols:opacity',
|
||||
description: 'Easily adjust the transparency of your PNG images. Simply upload your PNG file, use the slider to set the desired opacity level between 0 (fully transparent) and 1 (fully opaque), and download the modified image.',
|
||||
shortDescription: 'Adjust transparency of PNG images',
|
||||
keywords: ['opacity', 'transparency', 'png', 'alpha'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
@@ -8,7 +8,7 @@ export const tool = defineTool('png', {
|
||||
icon: 'material-symbols-light:compress',
|
||||
description:
|
||||
'This is a program that compresses PNG pictures. As soon as you paste your PNG picture in the input area, the program will compress it and show the result in the output area. In the options, you can adjust the compression level, as well as find the old and new picture file sizes.',
|
||||
shortDescription: 'Quicly compress a PNG',
|
||||
shortDescription: 'Quickly compress a PNG',
|
||||
keywords: ['compress', 'png'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 3.6 KiB |
@@ -1,17 +1,4 @@
|
||||
import { tool as pngCrop } from './crop/meta';
|
||||
import { tool as pngCompressPng } from './compress-png/meta';
|
||||
import { tool as convertJgpToPng } from './convert-jgp-to-png/meta';
|
||||
import { tool as pngCreateTransparent } from './create-transparent/meta';
|
||||
import { tool as changeColorsInPng } from './change-colors-in-png/meta';
|
||||
import { tool as changeOpacity } from './change-opacity/meta';
|
||||
import { tool as removeBackground } from './remove-background/meta';
|
||||
|
||||
export const pngTools = [
|
||||
pngCompressPng,
|
||||
pngCreateTransparent,
|
||||
changeColorsInPng,
|
||||
convertJgpToPng,
|
||||
changeOpacity,
|
||||
pngCrop,
|
||||
removeBackground
|
||||
];
|
||||
export const pngTools = [pngCompressPng, convertJgpToPng];
|
||||
|
@@ -1,13 +0,0 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('png', {
|
||||
name: 'Remove Background from PNG',
|
||||
path: 'remove-background',
|
||||
icon: 'mdi:image-remove',
|
||||
description:
|
||||
"World's simplest online tool to remove backgrounds from PNG images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.",
|
||||
shortDescription: 'Automatically remove backgrounds from images',
|
||||
keywords: ['remove', 'background', 'png', 'transparent', 'image', 'ai'],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
6
src/pages/tools/number/generic-calc/data/index.ts
Normal file
6
src/pages/tools/number/generic-calc/data/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ohmslaw from './ohmsLaw';
|
||||
import voltageDropInWire from './voltageDropInWire';
|
||||
import sphereArea from './sphereArea';
|
||||
import sphereVolume from './sphereVolume';
|
||||
|
||||
export default [ohmslaw, voltageDropInWire, sphereArea, sphereVolume];
|
46
src/pages/tools/number/generic-calc/data/ohmsLaw.ts
Normal file
46
src/pages/tools/number/generic-calc/data/ohmsLaw.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const ohmsLawCalc: GenericCalcType = {
|
||||
icon: 'mdi:ohm',
|
||||
keywords: [
|
||||
'ohm',
|
||||
'voltage',
|
||||
'current',
|
||||
'resistance',
|
||||
'electrical',
|
||||
'circuit',
|
||||
'electronics',
|
||||
'power',
|
||||
'V=IR'
|
||||
],
|
||||
shortDescription:
|
||||
"Calculate voltage, current, or resistance in electrical circuits using Ohm's Law",
|
||||
name: "Ohm's Law",
|
||||
path: 'ohms-law',
|
||||
description: 'Calculates voltage, current and resistance',
|
||||
longDescription:
|
||||
"This calculator applies Ohm's Law (V = I × R) to determine any of the three electrical parameters when the other two are known. Ohm's Law is a fundamental principle in electrical engineering that describes the relationship between voltage (V), current (I), and resistance (R). This tool is essential for electronics hobbyists, electrical engineers, and students working with circuits to quickly solve for unknown values in their electrical designs.",
|
||||
formula: 'V = I * R',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'V',
|
||||
title: 'Voltage',
|
||||
unit: 'volt',
|
||||
default: 5
|
||||
},
|
||||
{
|
||||
name: 'I',
|
||||
title: 'Current',
|
||||
unit: 'ampere',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'R',
|
||||
title: 'Resistance',
|
||||
unit: 'ohm'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default ohmsLawCalc;
|
41
src/pages/tools/number/generic-calc/data/sphereArea.ts
Normal file
41
src/pages/tools/number/generic-calc/data/sphereArea.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const areaSphere: GenericCalcType = {
|
||||
icon: 'ph:sphere-duotone',
|
||||
keywords: [
|
||||
'sphere',
|
||||
'area',
|
||||
'surface area',
|
||||
'geometry',
|
||||
'mathematics',
|
||||
'radius',
|
||||
'calculation',
|
||||
'3D',
|
||||
'shape'
|
||||
],
|
||||
shortDescription:
|
||||
'Calculate the surface area of a sphere based on its radius',
|
||||
name: 'Area of a Sphere',
|
||||
path: 'area-sphere',
|
||||
description: 'Area of a Sphere',
|
||||
longDescription:
|
||||
'This calculator determines the surface area of a sphere using the formula A = 4πr². You can either input the radius to find the surface area or enter the surface area to calculate the required radius. This tool is useful for students studying geometry, engineers working with spherical objects, and anyone needing to perform calculations involving spherical surfaces.',
|
||||
formula: 'A = 4 * pi * r**2',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'A',
|
||||
title: 'Area',
|
||||
unit: 'mm2'
|
||||
},
|
||||
{
|
||||
name: 'r',
|
||||
title: 'Radius',
|
||||
formula: 'r = sqrt(A/pi) / 2',
|
||||
unit: 'mm',
|
||||
default: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default areaSphere;
|
47
src/pages/tools/number/generic-calc/data/sphereVolume.ts
Normal file
47
src/pages/tools/number/generic-calc/data/sphereVolume.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
|
||||
const volumeSphere: GenericCalcType = {
|
||||
icon: 'gravity-ui:sphere',
|
||||
keywords: [
|
||||
'sphere',
|
||||
'volume',
|
||||
'geometry',
|
||||
'mathematics',
|
||||
'radius',
|
||||
'diameter',
|
||||
'calculation',
|
||||
'3D',
|
||||
'shape',
|
||||
'capacity'
|
||||
],
|
||||
shortDescription: 'Calculate the volume of a sphere using radius or diameter',
|
||||
name: 'Volume of a Sphere',
|
||||
path: 'volume-sphere',
|
||||
description: 'Volume of a Sphere',
|
||||
longDescription:
|
||||
'This calculator computes the volume of a sphere using the formula V = (4/3)πr³. You can input either the radius or diameter to find the volume, or enter the volume to determine the required radius. The tool is valuable for students, engineers, and professionals working with spherical objects in fields such as physics, engineering, and manufacturing.',
|
||||
formula: 'v = (4/3) * pi * r**3',
|
||||
presets: [],
|
||||
variables: [
|
||||
{
|
||||
name: 'v',
|
||||
title: 'Volume',
|
||||
unit: 'mm3'
|
||||
},
|
||||
{
|
||||
name: 'r',
|
||||
title: 'Radius',
|
||||
unit: 'mm',
|
||||
default: 1,
|
||||
alternates: [
|
||||
{
|
||||
title: 'Diameter',
|
||||
formula: 'x = 2 * v',
|
||||
unit: 'mm'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default volumeSphere;
|
49
src/pages/tools/number/generic-calc/data/types.ts
Normal file
49
src/pages/tools/number/generic-calc/data/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataTable } from '../../../../../datatables';
|
||||
import { ToolMeta } from '@tools/defineTool';
|
||||
|
||||
export interface AlternativeVarInfo {
|
||||
title: string;
|
||||
unit: string;
|
||||
defaultPrefix?: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
export interface GenericCalcType extends Omit<ToolMeta, 'component'> {
|
||||
formula: string;
|
||||
extraOutputs?: {
|
||||
title: string;
|
||||
formula: string;
|
||||
unit: string;
|
||||
// Si prefix default
|
||||
defaultPrefix?: string;
|
||||
}[];
|
||||
presets?: {
|
||||
title: string;
|
||||
source: DataTable;
|
||||
default: string;
|
||||
bind: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}[];
|
||||
variables: {
|
||||
name: string;
|
||||
title: string;
|
||||
unit: string;
|
||||
defaultPrefix?: string;
|
||||
// If absence, assume it's the default target var
|
||||
default?: number;
|
||||
|
||||
// If present and false, don't allow user to select this as output
|
||||
solvable?: boolean;
|
||||
|
||||
// Alternate rearrangement of the formula, to be used when calculating this.
|
||||
// If missing, the main formula is used with auto derivation.
|
||||
formula?: string;
|
||||
|
||||
// Alternates are alternate ways of entering the exact same thing,
|
||||
// like the diameter or radius. The formula for an alternate
|
||||
// can use only one variable, always called v, which is the main
|
||||
// variable it's an alternate of
|
||||
alternates?: AlternativeVarInfo[];
|
||||
}[];
|
||||
}
|
@@ -0,0 +1,95 @@
|
||||
import type { GenericCalcType } from './types';
|
||||
import material_electrical_properties from '../../../../../datatables/data/material_electrical_properties';
|
||||
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'
|
||||
],
|
||||
shortDescription:
|
||||
'Calculate voltage drop and power loss in electrical cables based on length, material, and current',
|
||||
name: 'Round trip voltage drop in cable',
|
||||
path: 'cable-voltage-drop',
|
||||
formula: 'x = (((p * L) / (A/10**6) ) *2) * I',
|
||||
description:
|
||||
'Calculates round trip voltage and power loss in a 2 conductor cable',
|
||||
longDescription:
|
||||
'This calculator helps determine the voltage drop and power loss in a two-conductor electrical cable. It takes into account the cable length, wire gauge (cross-sectional area), material resistivity, and current flow. The tool calculates the round-trip voltage drop, total resistance of the cable, and the power dissipated as heat. This is particularly useful for electrical engineers, electricians, and hobbyists when designing electrical systems to ensure voltage levels remain within acceptable limits at the load.',
|
||||
presets: [
|
||||
{
|
||||
title: 'Material',
|
||||
source: material_electrical_properties,
|
||||
default: 'Copper',
|
||||
bind: {
|
||||
p: 'resistivity_20c'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Wire Gauge',
|
||||
source: wire_gauge,
|
||||
default: '24 AWG',
|
||||
bind: {
|
||||
A: 'area'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
extraOutputs: [
|
||||
{
|
||||
title: 'Total Resistance',
|
||||
formula: '((p * L) / (A/10**6))*2',
|
||||
unit: 'Ω'
|
||||
},
|
||||
{
|
||||
title: 'Total Power Dissipated',
|
||||
formula: 'I**2 * (((p * L) / (A/10**6))*2)',
|
||||
unit: 'W'
|
||||
}
|
||||
],
|
||||
variables: [
|
||||
{
|
||||
name: 'L',
|
||||
title: 'Length',
|
||||
unit: 'meter',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'A',
|
||||
title: 'Wire Area',
|
||||
unit: 'mm2',
|
||||
default: 1
|
||||
},
|
||||
|
||||
{
|
||||
name: 'I',
|
||||
title: 'Current',
|
||||
unit: 'A',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'p',
|
||||
title: 'Resistivity',
|
||||
unit: 'Ω/m3',
|
||||
default: 1,
|
||||
defaultPrefix: 'n'
|
||||
},
|
||||
{
|
||||
name: 'x',
|
||||
title: 'Voltage Drop',
|
||||
unit: 'V'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default voltageDropInWire;
|
583
src/pages/tools/number/generic-calc/index.tsx
Normal file
583
src/pages/tools/number/generic-calc/index.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
MenuItem,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import NumericInputWithUnit from '@components/input/NumericInputWithUnit';
|
||||
import { UpdateField } from '@components/options/ToolOptions';
|
||||
import { InitialValuesType } from './types';
|
||||
import type { AlternativeVarInfo, GenericCalcType } from './data/types';
|
||||
import { dataTableLookup } from 'datatables';
|
||||
|
||||
import nerdamer from 'nerdamer-prime';
|
||||
import 'nerdamer-prime/Algebra';
|
||||
import 'nerdamer-prime/Solve';
|
||||
import 'nerdamer-prime/Calculus';
|
||||
import Qty from 'js-quantities';
|
||||
import { CustomSnackBarContext } from 'contexts/CustomSnackBarContext';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
|
||||
function numericSolveEquationFor(
|
||||
equation: string,
|
||||
varName: string,
|
||||
variables: { [key: string]: number }
|
||||
) {
|
||||
let expr = nerdamer(equation);
|
||||
for (const key in variables) {
|
||||
expr = expr.sub(key, variables[key].toString());
|
||||
}
|
||||
|
||||
let result: nerdamer.Expression | nerdamer.Expression[] =
|
||||
expr.solveFor(varName);
|
||||
|
||||
// Sometimes the result is an array, check for it while keeping linter happy
|
||||
if ((result as unknown as nerdamer.Expression).toDecimal === undefined) {
|
||||
result = (result as unknown as nerdamer.Expression[])[0];
|
||||
}
|
||||
|
||||
return parseFloat(
|
||||
(result as unknown as nerdamer.Expression).evaluate().toDecimal()
|
||||
);
|
||||
}
|
||||
|
||||
export default async function makeTool(
|
||||
calcData: GenericCalcType
|
||||
): Promise<React.JSXElementConstructor<ToolComponentProps>> {
|
||||
const initialValues: InitialValuesType = {
|
||||
outputVariable: '',
|
||||
vars: {},
|
||||
presets: {}
|
||||
};
|
||||
|
||||
return function GenericCalc({ title }: ToolComponentProps) {
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const theme = useTheme();
|
||||
const lessThanSmall = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// For UX purposes we need to track what vars are
|
||||
const [valsBoundToPreset, setValsBoundToPreset] = useState<{
|
||||
[key: string]: string;
|
||||
}>({});
|
||||
|
||||
const [extraOutputs, setExtraOutputs] = useState<{
|
||||
[key: string]: number;
|
||||
}>({});
|
||||
|
||||
const updateVarField = (
|
||||
name: string,
|
||||
value: number,
|
||||
unit: string,
|
||||
values: InitialValuesType,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
// Make copy
|
||||
const newVars = { ...values.vars };
|
||||
newVars[name] = {
|
||||
value,
|
||||
unit: unit
|
||||
};
|
||||
updateFieldFunc('vars', newVars);
|
||||
};
|
||||
|
||||
const handleSelectedTargetChange = (
|
||||
varName: string,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
updateFieldFunc('outputVariable', varName);
|
||||
};
|
||||
|
||||
const handleSelectedPresetChange = (
|
||||
selection: string,
|
||||
preset: string,
|
||||
currentValues: InitialValuesType,
|
||||
updateFieldFunc: UpdateField<InitialValuesType>
|
||||
) => {
|
||||
const newPresets = { ...currentValues.presets };
|
||||
newPresets[selection] = preset;
|
||||
updateFieldFunc('presets', newPresets);
|
||||
|
||||
// Clear old selection using setState callback pattern
|
||||
setValsBoundToPreset((prevState) => {
|
||||
const newState = { ...prevState };
|
||||
// Remove all keys bound to this selection
|
||||
Object.keys(newState).forEach((key) => {
|
||||
if (newState[key] === selection) {
|
||||
delete newState[key];
|
||||
}
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
|
||||
const selectionData = calcData.presets?.find(
|
||||
(sel) => sel.title === selection
|
||||
);
|
||||
|
||||
if (preset && preset != '<custom>') {
|
||||
if (selectionData) {
|
||||
// Create an object with the new bindings
|
||||
const newBindings: { [key: string]: string } = {};
|
||||
|
||||
for (const key in selectionData.bind) {
|
||||
// Add to newBindings for later state update
|
||||
newBindings[key] = selection;
|
||||
|
||||
if (currentValues.outputVariable === key) {
|
||||
handleSelectedTargetChange('', updateFieldFunc);
|
||||
}
|
||||
|
||||
updateVarField(
|
||||
key,
|
||||
|
||||
dataTableLookup(selectionData.source, preset)[
|
||||
selectionData.bind[key]
|
||||
],
|
||||
|
||||
selectionData.source.columns[selectionData.bind[key]]?.unit || '',
|
||||
currentValues,
|
||||
updateFieldFunc
|
||||
);
|
||||
}
|
||||
|
||||
// Update state with new bindings
|
||||
setValsBoundToPreset((prevState) => ({
|
||||
...prevState,
|
||||
...newBindings
|
||||
}));
|
||||
} else {
|
||||
throw new Error(
|
||||
`Preset "${preset}" is not valid for selection "${selection}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
calcData.variables.forEach((variable) => {
|
||||
if (variable.solvable === undefined) {
|
||||
variable.solvable = true;
|
||||
}
|
||||
if (variable.default === undefined) {
|
||||
initialValues.vars[variable.name] = {
|
||||
value: NaN,
|
||||
unit: variable.unit
|
||||
};
|
||||
initialValues.outputVariable = variable.name;
|
||||
} else {
|
||||
initialValues.vars[variable.name] = {
|
||||
value: variable.default || 0,
|
||||
unit: variable.unit
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
calcData.presets?.forEach((selection) => {
|
||||
initialValues.presets[selection.title] = selection.default;
|
||||
if (selection.default == '<custom>') return;
|
||||
for (const key in selection.bind) {
|
||||
initialValues.vars[key] = {
|
||||
value: dataTableLookup(selection.source, selection.default)[
|
||||
selection.bind[key]
|
||||
],
|
||||
|
||||
unit: selection.source.columns[selection.bind[key]]?.unit || ''
|
||||
};
|
||||
// We'll set this in useEffect instead of directly modifying state
|
||||
}
|
||||
});
|
||||
|
||||
function getAlternate(
|
||||
alternateInfo: AlternativeVarInfo,
|
||||
mainInfo: GenericCalcType['variables'][number],
|
||||
mainValue: {
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
) {
|
||||
if (isNaN(mainValue.value)) return NaN;
|
||||
const canonicalValue = Qty(mainValue.value, mainValue.unit).to(
|
||||
mainInfo.unit
|
||||
).scalar;
|
||||
|
||||
return numericSolveEquationFor(alternateInfo.formula, 'x', {
|
||||
v: canonicalValue
|
||||
});
|
||||
}
|
||||
|
||||
function getMainFromAlternate(
|
||||
alternateInfo: AlternativeVarInfo,
|
||||
mainInfo: GenericCalcType['variables'][number],
|
||||
alternateValue: {
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
) {
|
||||
if (isNaN(alternateValue.value)) return NaN;
|
||||
const canonicalValue = Qty(alternateValue.value, alternateValue.unit).to(
|
||||
alternateInfo.unit
|
||||
).scalar;
|
||||
|
||||
return numericSolveEquationFor(alternateInfo.formula, 'v', {
|
||||
x: canonicalValue
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
inputComponent={null}
|
||||
initialValues={initialValues}
|
||||
toolInfo={{
|
||||
title: calcData.name,
|
||||
description: calcData.longDescription
|
||||
}}
|
||||
verticalGroups
|
||||
getGroups={({ values, updateField }) => [
|
||||
...(calcData.presets?.length
|
||||
? [
|
||||
{
|
||||
title: 'Presets',
|
||||
component: (
|
||||
<Grid container spacing={2} maxWidth={500}>
|
||||
{calcData.presets?.map((preset) => (
|
||||
<Grid item xs={12} key={preset.title}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
>
|
||||
<Typography>{preset.title}</Typography>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id="combo-box-demo"
|
||||
value={values.presets[preset.title]}
|
||||
options={[
|
||||
'<custom>',
|
||||
...Object.keys(preset.source.data).sort()
|
||||
]}
|
||||
sx={{ width: '80%' }}
|
||||
onChange={(event, newValue) => {
|
||||
handleSelectedPresetChange(
|
||||
preset.title,
|
||||
newValue || '',
|
||||
values,
|
||||
updateField
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Preset" />
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: 'Variables',
|
||||
component: (
|
||||
<Box>
|
||||
{lessThanSmall ? (
|
||||
<Stack
|
||||
direction={'column'}
|
||||
spacing={2}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
>
|
||||
<Typography>Solve for</Typography>
|
||||
<Select
|
||||
sx={{ width: '80%' }}
|
||||
fullWidth
|
||||
value={values.outputVariable}
|
||||
onChange={(event) =>
|
||||
handleSelectedTargetChange(
|
||||
event.target.value,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
>
|
||||
{calcData.variables.map((variable) => (
|
||||
<MenuItem
|
||||
disabled={
|
||||
valsBoundToPreset[variable.name] !== undefined ||
|
||||
variable.solvable === false
|
||||
}
|
||||
key={variable.name}
|
||||
value={variable.name}
|
||||
>
|
||||
{variable.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
) : (
|
||||
<Grid container spacing={2} sx={{ mb: 2 }}>
|
||||
<Grid item xs={10}></Grid>
|
||||
<Grid item xs={2}>
|
||||
<Typography fontWeight="bold" align="center">
|
||||
Solve For
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{calcData.variables.map((variable) => (
|
||||
<Box
|
||||
key={variable.name}
|
||||
sx={{
|
||||
my: 3,
|
||||
p: 1,
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={lessThanSmall ? 12 : 10}>
|
||||
<Box>
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography sx={{ minWidth: '8%' }}>
|
||||
{variable.title}
|
||||
</Typography>
|
||||
<NumericInputWithUnit
|
||||
defaultPrefix={variable.defaultPrefix}
|
||||
value={values.vars[variable.name]}
|
||||
disabled={
|
||||
values.outputVariable === variable.name ||
|
||||
valsBoundToPreset[variable.name] !== undefined
|
||||
}
|
||||
disableChangingUnit={
|
||||
valsBoundToPreset[variable.name] !== undefined
|
||||
}
|
||||
onOwnChange={(val) =>
|
||||
updateVarField(
|
||||
variable.name,
|
||||
val.value,
|
||||
val.unit,
|
||||
values,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{variable.alternates?.map((alt) => (
|
||||
<Box key={alt.title}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography sx={{ minWidth: '8%' }}>
|
||||
{alt.title}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<NumericInputWithUnit
|
||||
key={alt.title}
|
||||
defaultPrefix={alt.defaultPrefix || ''}
|
||||
value={{
|
||||
value:
|
||||
getAlternate(
|
||||
alt,
|
||||
variable,
|
||||
values.vars[variable.name]
|
||||
) || NaN,
|
||||
unit: alt.unit || ''
|
||||
}}
|
||||
disabled={
|
||||
values.outputVariable ===
|
||||
variable.name ||
|
||||
valsBoundToPreset[variable.name] !==
|
||||
undefined
|
||||
}
|
||||
disableChangingUnit={
|
||||
valsBoundToPreset[variable.name] !==
|
||||
undefined
|
||||
}
|
||||
onOwnChange={(val) =>
|
||||
updateVarField(
|
||||
variable.name,
|
||||
getMainFromAlternate(
|
||||
alt,
|
||||
variable,
|
||||
val
|
||||
),
|
||||
variable.unit,
|
||||
values,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{!lessThanSmall && (
|
||||
<Grid
|
||||
item
|
||||
xs={2}
|
||||
sx={{ display: 'flex', justifyContent: 'center' }}
|
||||
>
|
||||
<Radio
|
||||
value={variable.name}
|
||||
checked={values.outputVariable === variable.name}
|
||||
disabled={
|
||||
valsBoundToPreset[variable.name] !== undefined ||
|
||||
variable.solvable === false
|
||||
}
|
||||
onClick={() =>
|
||||
handleSelectedTargetChange(
|
||||
variable.name,
|
||||
updateField
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
...(calcData.extraOutputs
|
||||
? [
|
||||
{
|
||||
title: 'Extra outputs',
|
||||
component: (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
{calcData.extraOutputs?.map((extraOutput) => (
|
||||
<Grid item xs={12} key={extraOutput.title}>
|
||||
<Stack spacing={1}>
|
||||
<Typography>{extraOutput.title}</Typography>
|
||||
<NumericInputWithUnit
|
||||
disabled={true}
|
||||
defaultPrefix={extraOutput.defaultPrefix}
|
||||
value={{
|
||||
value: extraOutputs[extraOutput.title],
|
||||
unit: extraOutput.unit
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
compute={(values) => {
|
||||
if (values.outputVariable === '') {
|
||||
showSnackBar('Please select a solve for variable', 'error');
|
||||
return;
|
||||
}
|
||||
let expr: nerdamer.Expression | null = null;
|
||||
|
||||
for (const variable of calcData.variables) {
|
||||
if (variable.name === values.outputVariable) {
|
||||
if (variable.formula !== undefined) {
|
||||
expr = nerdamer(variable.formula);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expr == null) {
|
||||
expr = nerdamer(calcData.formula);
|
||||
}
|
||||
if (expr == null) {
|
||||
throw new Error('No formula found');
|
||||
}
|
||||
|
||||
Object.keys(values.vars).forEach((key) => {
|
||||
if (key === values.outputVariable) return;
|
||||
if (expr === null) {
|
||||
throw new Error('Math fail');
|
||||
}
|
||||
expr = expr.sub(key, values.vars[key].value.toString());
|
||||
});
|
||||
|
||||
let result: nerdamer.Expression | nerdamer.Expression[] =
|
||||
expr.solveFor(values.outputVariable);
|
||||
|
||||
// Sometimes the result is an array
|
||||
if (
|
||||
(result as unknown as nerdamer.Expression).toDecimal === undefined
|
||||
) {
|
||||
if ((result as unknown as nerdamer.Expression[])?.length < 1) {
|
||||
values.vars[values.outputVariable].value = NaN;
|
||||
if (calcData.extraOutputs !== undefined) {
|
||||
// Update extraOutputs using setState
|
||||
setExtraOutputs((prevState) => {
|
||||
const newState = { ...prevState };
|
||||
for (let i = 0; i < calcData.extraOutputs!.length; i++) {
|
||||
const extraOutput = calcData.extraOutputs![i];
|
||||
newState[extraOutput.title] = NaN;
|
||||
}
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
throw new Error('No solution found for this input');
|
||||
}
|
||||
result = (result as unknown as nerdamer.Expression[])[0];
|
||||
}
|
||||
|
||||
if (result) {
|
||||
if (values.vars[values.outputVariable] != undefined) {
|
||||
values.vars[values.outputVariable].value = parseFloat(
|
||||
(result as unknown as nerdamer.Expression)
|
||||
.evaluate()
|
||||
.toDecimal()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
values.vars[values.outputVariable].value = NaN;
|
||||
}
|
||||
|
||||
if (calcData.extraOutputs !== undefined) {
|
||||
for (let i = 0; i < calcData.extraOutputs.length; i++) {
|
||||
const extraOutput = calcData.extraOutputs[i];
|
||||
|
||||
let expr = nerdamer(extraOutput.formula);
|
||||
|
||||
Object.keys(values.vars).forEach((key) => {
|
||||
expr = expr.sub(key, values.vars[key].value.toString());
|
||||
});
|
||||
|
||||
// todo could this have multiple solutions too?
|
||||
const result: nerdamer.Expression = expr.evaluate();
|
||||
|
||||
if (result) {
|
||||
// Update extraOutputs state properly
|
||||
setExtraOutputs((prevState) => ({
|
||||
...prevState,
|
||||
[extraOutput.title]: parseFloat(result.toDecimal())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
28
src/pages/tools/number/generic-calc/meta.ts
Normal file
28
src/pages/tools/number/generic-calc/meta.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DefinedTool, defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
import type { GenericCalcType } from './data/types';
|
||||
import allGenericCalcs from './data/index';
|
||||
|
||||
async function importComponent(data: GenericCalcType) {
|
||||
const x = await import('./index');
|
||||
return { default: await x.default(data) };
|
||||
}
|
||||
|
||||
const tools: DefinedTool[] = [];
|
||||
|
||||
allGenericCalcs.forEach((x) => {
|
||||
async function importComponent2() {
|
||||
return await importComponent(x);
|
||||
}
|
||||
|
||||
tools.push(
|
||||
defineTool('number', {
|
||||
...x,
|
||||
path: 'generic-calc/' + x.path,
|
||||
keywords: ['calculator', 'math', ...x.keywords],
|
||||
component: lazy(importComponent2)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export { tools };
|
14
src/pages/tools/number/generic-calc/types.ts
Normal file
14
src/pages/tools/number/generic-calc/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type InitialValuesType = {
|
||||
vars: {
|
||||
[key: string]: {
|
||||
value: number;
|
||||
unit: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Track preset selections
|
||||
presets: {
|
||||
[key: string]: string;
|
||||
};
|
||||
outputVariable: string;
|
||||
};
|
@@ -1,5 +1,10 @@
|
||||
import { tool as numberSum } from './sum/meta';
|
||||
import { tool as numberGenerate } from './generate/meta';
|
||||
import { tool as numberArithmeticSequence } from './arithmetic-sequence/meta';
|
||||
|
||||
export const numberTools = [numberSum, numberGenerate, numberArithmeticSequence];
|
||||
import { tools as genericCalcTools } from './generic-calc/meta';
|
||||
export const numberTools = [
|
||||
numberSum,
|
||||
numberGenerate,
|
||||
numberArithmeticSequence,
|
||||
...genericCalcTools
|
||||
];
|
||||
|
222
src/pages/tools/pdf/compress-pdf/index.tsx
Normal file
222
src/pages/tools/pdf/compress-pdf/index.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolPdfInput from '@components/input/ToolPdfInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import { CardExampleType } from '@components/examples/ToolExamples';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { CompressionLevel, InitialValuesType } from './types';
|
||||
import { compressPdf } from './service';
|
||||
import SimpleRadio from '@components/options/SimpleRadio';
|
||||
import { CustomSnackBarContext } from '../../../../contexts/CustomSnackBarContext';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
compressionLevel: 'medium'
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Low Compression',
|
||||
description: 'Slightly reduce file size with minimal quality loss',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
compressionLevel: 'low'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Medium Compression',
|
||||
description: 'Balance between file size and quality',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
compressionLevel: 'medium'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'High Compression',
|
||||
description: 'Maximum file size reduction with some quality loss',
|
||||
sampleText: '',
|
||||
sampleResult: '',
|
||||
sampleOptions: {
|
||||
compressionLevel: 'high'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default function CompressPdf({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [resultSize, setResultSize] = useState<string>('');
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [fileInfo, setFileInfo] = useState<{
|
||||
size: string;
|
||||
pages: number;
|
||||
} | null>(null);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
// Get the PDF info when a file is uploaded
|
||||
useEffect(() => {
|
||||
const getPdfInfo = async () => {
|
||||
if (!input) {
|
||||
setFileInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await input.arrayBuffer();
|
||||
const pdf = await PDFDocument.load(arrayBuffer);
|
||||
const pages = pdf.getPageCount();
|
||||
const size = formatFileSize(input.size);
|
||||
|
||||
setFileInfo({ size, pages });
|
||||
} catch (error) {
|
||||
console.error('Error getting PDF info:', error);
|
||||
setFileInfo(null);
|
||||
showSnackBar(
|
||||
'Error reading PDF file. Please make sure it is a valid PDF.',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
getPdfInfo();
|
||||
}, [input]);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const compressedPdf = await compressPdf(input, values);
|
||||
setResult(compressedPdf);
|
||||
|
||||
// Log compression results
|
||||
const compressionRatio = (compressedPdf.size / input.size) * 100;
|
||||
console.log(`Compression Ratio: ${compressionRatio.toFixed(2)}%`);
|
||||
setResultSize(formatFileSize(compressedPdf.size));
|
||||
} catch (error) {
|
||||
console.error('Error compressing PDF:', error);
|
||||
showSnackBar(
|
||||
`Failed to compress PDF: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
'error'
|
||||
);
|
||||
setResult(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const compressionOptions: {
|
||||
value: CompressionLevel;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'low',
|
||||
label: 'Low Compression',
|
||||
description: 'Slightly reduce file size with minimal quality loss'
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Medium Compression',
|
||||
description: 'Balance between file size and quality'
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'High Compression',
|
||||
description: 'Maximum file size reduction with some quality loss'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={initialValues}
|
||||
compute={compute}
|
||||
inputComponent={
|
||||
<ToolPdfInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['application/pdf']}
|
||||
title={'Input PDF'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Compressed PDF'}
|
||||
value={result}
|
||||
extension={'pdf'}
|
||||
loading={isProcessing}
|
||||
loadingText={'Compressing PDF'}
|
||||
/>
|
||||
}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: 'Compression Settings',
|
||||
component: (
|
||||
<Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Compression Level
|
||||
</Typography>
|
||||
|
||||
{compressionOptions.map((option) => (
|
||||
<SimpleRadio
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
description={option.description}
|
||||
checked={values.compressionLevel === option.value}
|
||||
onClick={() => {
|
||||
updateField('compressionLevel', option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{fileInfo && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
File size: <strong>{fileInfo.size}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Pages: <strong>{fileInfo.pages}</strong>
|
||||
</Typography>
|
||||
{resultSize && (
|
||||
<Typography variant="body2">
|
||||
Compressed file size: <strong>{resultSize}</strong>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
28
src/pages/tools/pdf/compress-pdf/meta.ts
Normal file
28
src/pages/tools/pdf/compress-pdf/meta.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('pdf', {
|
||||
name: 'Compress PDF',
|
||||
path: 'compress-pdf',
|
||||
icon: 'material-symbols:compress',
|
||||
description:
|
||||
'Reduce PDF file size while maintaining quality using Ghostscript',
|
||||
shortDescription: 'Compress PDF files securely in your browser',
|
||||
keywords: [
|
||||
'pdf',
|
||||
'compress',
|
||||
'reduce',
|
||||
'size',
|
||||
'optimize',
|
||||
'shrink',
|
||||
'file size',
|
||||
'ghostscript',
|
||||
'secure',
|
||||
'private',
|
||||
'browser',
|
||||
'webassembly'
|
||||
],
|
||||
longDescription:
|
||||
'Compress PDF files securely in your browser using Ghostscript. Your files never leave your device, ensuring complete privacy while reducing file sizes for email sharing, uploading to websites, or saving storage space. Powered by WebAssembly technology.',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
28
src/pages/tools/pdf/compress-pdf/service.ts
Normal file
28
src/pages/tools/pdf/compress-pdf/service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { compressWithGhostScript } from '../../../../lib/ghostscript/worker-init';
|
||||
import { loadPDFData } from '../utils';
|
||||
|
||||
/**
|
||||
* Compresses a PDF file using either Ghostscript WASM (preferred)
|
||||
* or falls back to pdf-lib if WASM fails
|
||||
*
|
||||
* @param pdfFile - The PDF file to compress
|
||||
* @param options - Compression options including compression level
|
||||
* @returns A Promise that resolves to a compressed PDF File
|
||||
*/
|
||||
export async function compressPdf(
|
||||
pdfFile: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File> {
|
||||
// Check if file is a PDF
|
||||
if (pdfFile.type !== 'application/pdf') {
|
||||
throw new Error('The provided file is not a PDF');
|
||||
}
|
||||
|
||||
const dataObject = {
|
||||
psDataURL: URL.createObjectURL(pdfFile),
|
||||
compressionLevel: options.compressionLevel
|
||||
};
|
||||
const compressedFileUrl: string = await compressWithGhostScript(dataObject);
|
||||
return await loadPDFData(compressedFileUrl, pdfFile.name);
|
||||
}
|
5
src/pages/tools/pdf/compress-pdf/types.ts
Normal file
5
src/pages/tools/pdf/compress-pdf/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type CompressionLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
export type InitialValuesType = {
|
||||
compressionLevel: CompressionLevel;
|
||||
};
|
@@ -4,3 +4,13 @@ import { meta as mergePdf } from './merge-pdf/meta';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
|
||||
export const pdfTools: DefinedTool[] = [splitPdfMeta, pdfRotatePdf, mergePdf];
|
||||
import { tool as compressPdfTool } from './compress-pdf/meta';
|
||||
import { tool as protectPdfTool } from './protect-pdf/meta';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
|
||||
export const pdfTools: DefinedTool[] = [
|
||||
splitPdfMeta,
|
||||
pdfRotatePdf,
|
||||
compressPdfTool,
|
||||
protectPdfTool
|
||||
];
|
||||
|
110
src/pages/tools/pdf/protect-pdf/index.tsx
Normal file
110
src/pages/tools/pdf/protect-pdf/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolPdfInput from '@components/input/ToolPdfInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import { InitialValuesType } from './types';
|
||||
import { protectPdf } from './service';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import { CustomSnackBarContext } from '../../../../contexts/CustomSnackBarContext';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
|
||||
export default function ProtectPdf({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
|
||||
const compute = async (values: InitialValuesType, input: File | null) => {
|
||||
if (!input) return;
|
||||
|
||||
try {
|
||||
// Validate passwords match
|
||||
if (values.password !== values.confirmPassword) {
|
||||
showSnackBar('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password is not empty
|
||||
if (!values.password) {
|
||||
showSnackBar('Password cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
const protectedPdf = await protectPdf(input, values);
|
||||
setResult(protectedPdf);
|
||||
} catch (error) {
|
||||
console.error('Error protecting PDF:', error);
|
||||
showSnackBar(
|
||||
`Failed to protect PDF: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
'error'
|
||||
);
|
||||
setResult(null);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={initialValues}
|
||||
compute={compute}
|
||||
inputComponent={
|
||||
<ToolPdfInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['application/pdf']}
|
||||
title={'Input PDF'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Protected PDF'}
|
||||
value={result}
|
||||
extension={'pdf'}
|
||||
loading={isProcessing}
|
||||
loadingText={'Protecting PDF'}
|
||||
/>
|
||||
}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: 'Password Settings',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
title="Password"
|
||||
description="Enter a password to protect your PDF"
|
||||
placeholder="Enter password"
|
||||
type="password"
|
||||
value={values.password}
|
||||
onOwnChange={(value) => updateField('password', value)}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
title="Confirm Password"
|
||||
description="Re-enter your password to confirm"
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
value={values.confirmPassword}
|
||||
onOwnChange={(value) => updateField('confirmPassword', value)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
27
src/pages/tools/pdf/protect-pdf/meta.ts
Normal file
27
src/pages/tools/pdf/protect-pdf/meta.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('pdf', {
|
||||
name: 'Protect PDF',
|
||||
path: 'protect-pdf',
|
||||
icon: 'material-symbols:lock',
|
||||
description:
|
||||
'Add password protection to your PDF files securely in your browser',
|
||||
shortDescription: 'Password protect PDF files securely',
|
||||
keywords: [
|
||||
'pdf',
|
||||
'protect',
|
||||
'password',
|
||||
'secure',
|
||||
'encrypt',
|
||||
'lock',
|
||||
'private',
|
||||
'confidential',
|
||||
'security',
|
||||
'browser',
|
||||
'encryption'
|
||||
],
|
||||
longDescription:
|
||||
'Add password protection to your PDF files securely in your browser. Your files never leave your device, ensuring complete privacy while securing your documents with password encryption. Perfect for protecting sensitive information, confidential documents, or personal data.',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
45
src/pages/tools/pdf/protect-pdf/service.ts
Normal file
45
src/pages/tools/pdf/protect-pdf/service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { InitialValuesType } from './types';
|
||||
import {
|
||||
compressWithGhostScript,
|
||||
protectWithGhostScript
|
||||
} from '../../../../lib/ghostscript/worker-init';
|
||||
import { loadPDFData } from '../utils';
|
||||
|
||||
/**
|
||||
* Protects a PDF file with a password
|
||||
*
|
||||
* @param pdfFile - The PDF file to protect
|
||||
* @param options - Protection options including password and protection type
|
||||
* @returns A Promise that resolves to a password-protected PDF File
|
||||
*/
|
||||
export async function protectPdf(
|
||||
pdfFile: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File> {
|
||||
// Check if file is a PDF
|
||||
if (pdfFile.type !== 'application/pdf') {
|
||||
throw new Error('The provided file is not a PDF');
|
||||
}
|
||||
|
||||
// Check if passwords match
|
||||
if (options.password !== options.confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
// Check if password is empty
|
||||
if (!options.password) {
|
||||
throw new Error('Password cannot be empty');
|
||||
}
|
||||
|
||||
const dataObject = {
|
||||
psDataURL: URL.createObjectURL(pdfFile),
|
||||
password: options.password
|
||||
};
|
||||
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
|
||||
console.log('protected', protectedFileUrl);
|
||||
return await loadPDFData(
|
||||
protectedFileUrl,
|
||||
pdfFile.name.replace('.pdf', '-protected.pdf')
|
||||
);
|
||||
}
|
6
src/pages/tools/pdf/protect-pdf/types.ts
Normal file
6
src/pages/tools/pdf/protect-pdf/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ProtectionType = 'owner' | 'user';
|
||||
|
||||
export type InitialValuesType = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
};
|
16
src/pages/tools/pdf/utils.ts
Normal file
16
src/pages/tools/pdf/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function loadPDFData(url: string, filename: string): Promise<File> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function () {
|
||||
window.URL.revokeObjectURL(url);
|
||||
const blob = new Blob([xhr.response], { type: 'application/pdf' });
|
||||
const newFile = new File([blob], filename, {
|
||||
type: 'application/pdf'
|
||||
});
|
||||
resolve(newFile);
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
}
|
@@ -50,26 +50,31 @@ const validationSchema = Yup.object({
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: 'local', label: 'Local Time' },
|
||||
...Intl.supportedValuesOf('timeZone')
|
||||
.map((tz) => {
|
||||
const formatter = new Intl.DateTimeFormat('en', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset'
|
||||
});
|
||||
...Array.from(
|
||||
new Map(
|
||||
Intl.supportedValuesOf('timeZone').map((tz) => {
|
||||
const formatter = new Intl.DateTimeFormat('en', {
|
||||
timeZone: tz,
|
||||
timeZoneName: 'shortOffset'
|
||||
});
|
||||
|
||||
const offset =
|
||||
formatter
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === 'timeZoneName')?.value || '';
|
||||
const offset =
|
||||
formatter
|
||||
.formatToParts(new Date())
|
||||
.find((part) => part.type === 'timeZoneName')?.value || '';
|
||||
|
||||
return {
|
||||
value: offset.replace('UTC', 'GMT'),
|
||||
label: `${offset.replace('UTC', 'GMT')} (${tz})`
|
||||
};
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.value.localeCompare(b.value, undefined, { numeric: true })
|
||||
)
|
||||
const value = offset.replace('UTC', 'GMT');
|
||||
|
||||
return [
|
||||
value, // key for Map to ensure uniqueness
|
||||
{
|
||||
value,
|
||||
label: `${value} (${tz})`
|
||||
}
|
||||
];
|
||||
})
|
||||
).values()
|
||||
).sort((a, b) => a.value.localeCompare(b.value, undefined, { numeric: true }))
|
||||
];
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
|
@@ -1,3 +1,12 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const unitHierarchy = [
|
||||
'years',
|
||||
'months',
|
||||
@@ -11,70 +20,153 @@ export const unitHierarchy = [
|
||||
export type TimeUnit = (typeof unitHierarchy)[number];
|
||||
export type TimeDifference = Record<TimeUnit, number>;
|
||||
|
||||
// Mapping common abbreviations to IANA time zone names
|
||||
export const tzMap: { [abbr: string]: string } = {
|
||||
EST: 'America/New_York',
|
||||
EDT: 'America/New_York',
|
||||
CST: 'America/Chicago',
|
||||
CDT: 'America/Chicago',
|
||||
MST: 'America/Denver',
|
||||
MDT: 'America/Denver',
|
||||
PST: 'America/Los_Angeles',
|
||||
PDT: 'America/Los_Angeles',
|
||||
GMT: 'Etc/GMT',
|
||||
UTC: 'Etc/UTC'
|
||||
// add more mappings as needed
|
||||
};
|
||||
|
||||
// Parse a date string with a time zone abbreviation,
|
||||
// e.g. "02/02/2024 14:55 EST"
|
||||
export const parseWithTZ = (dateTimeStr: string): dayjs.Dayjs => {
|
||||
const parts = dateTimeStr.trim().split(' ');
|
||||
const tzAbbr = parts.pop()!; // extract the timezone part (e.g., EST)
|
||||
const dateTimePart = parts.join(' ');
|
||||
const tzName = tzMap[tzAbbr];
|
||||
if (!tzName) {
|
||||
throw new Error(`Timezone abbreviation ${tzAbbr} not supported`);
|
||||
}
|
||||
// Parse using the format "MM/DD/YYYY HH:mm" in the given time zone
|
||||
return dayjs.tz(dateTimePart, 'MM/DD/YYYY HH:mm', tzName);
|
||||
};
|
||||
|
||||
export const calculateTimeBetweenDates = (
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): TimeDifference => {
|
||||
if (endDate < startDate) {
|
||||
const temp = startDate;
|
||||
startDate = endDate;
|
||||
endDate = temp;
|
||||
let start = dayjs(startDate);
|
||||
let end = dayjs(endDate);
|
||||
|
||||
// Swap dates if start is after end
|
||||
if (end.isBefore(start)) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
const milliseconds = endDate.getTime() - startDate.getTime();
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
// Calculate each unit incrementally so that the remainder is applied for subsequent units.
|
||||
const years = end.diff(start, 'year');
|
||||
const startPlusYears = start.add(years, 'year');
|
||||
|
||||
// Approximate months and years
|
||||
const startYear = startDate.getFullYear();
|
||||
const startMonth = startDate.getMonth();
|
||||
const endYear = endDate.getFullYear();
|
||||
const endMonth = endDate.getMonth();
|
||||
const months = end.diff(startPlusYears, 'month');
|
||||
const startPlusMonths = startPlusYears.add(months, 'month');
|
||||
|
||||
const months = (endYear - startYear) * 12 + (endMonth - startMonth);
|
||||
const years = Math.floor(months / 12);
|
||||
const days = end.diff(startPlusMonths, 'day');
|
||||
const startPlusDays = startPlusMonths.add(days, 'day');
|
||||
|
||||
const hours = end.diff(startPlusDays, 'hour');
|
||||
const startPlusHours = startPlusDays.add(hours, 'hour');
|
||||
|
||||
const minutes = end.diff(startPlusHours, 'minute');
|
||||
const startPlusMinutes = startPlusHours.add(minutes, 'minute');
|
||||
|
||||
const seconds = end.diff(startPlusMinutes, 'second');
|
||||
const startPlusSeconds = startPlusMinutes.add(seconds, 'second');
|
||||
|
||||
const milliseconds = end.diff(startPlusSeconds, 'millisecond');
|
||||
|
||||
return {
|
||||
milliseconds,
|
||||
seconds,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
years,
|
||||
months,
|
||||
years
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
milliseconds
|
||||
};
|
||||
};
|
||||
|
||||
// Calculate duration between two date strings with timezone abbreviations
|
||||
export const getDuration = (
|
||||
startStr: string,
|
||||
endStr: string
|
||||
): TimeDifference => {
|
||||
const start = parseWithTZ(startStr);
|
||||
const end = parseWithTZ(endStr);
|
||||
|
||||
if (end.isBefore(start)) {
|
||||
throw new Error('End date must be after start date');
|
||||
}
|
||||
|
||||
return calculateTimeBetweenDates(start.toDate(), end.toDate());
|
||||
};
|
||||
|
||||
export const formatTimeDifference = (
|
||||
difference: TimeDifference,
|
||||
includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1)
|
||||
includeUnits: TimeUnit[] = unitHierarchy.slice(0, -2)
|
||||
): string => {
|
||||
const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [
|
||||
{ key: 'years', value: difference.years },
|
||||
{ key: 'months', value: difference.months, divisor: 12 },
|
||||
{ key: 'days', value: difference.days, divisor: 30 },
|
||||
{ key: 'hours', value: difference.hours, divisor: 24 },
|
||||
{ key: 'minutes', value: difference.minutes, divisor: 60 },
|
||||
{ key: 'seconds', value: difference.seconds, divisor: 60 }
|
||||
// First normalize the values (convert 24 hours to 1 day, etc.)
|
||||
const normalized = { ...difference };
|
||||
|
||||
// Convert milliseconds to seconds
|
||||
if (normalized.milliseconds >= 1000) {
|
||||
const additionalSeconds = Math.floor(normalized.milliseconds / 1000);
|
||||
normalized.seconds += additionalSeconds;
|
||||
normalized.milliseconds %= 1000;
|
||||
}
|
||||
|
||||
// Convert seconds to minutes
|
||||
if (normalized.seconds >= 60) {
|
||||
const additionalMinutes = Math.floor(normalized.seconds / 60);
|
||||
normalized.minutes += additionalMinutes;
|
||||
normalized.seconds %= 60;
|
||||
}
|
||||
|
||||
// Convert minutes to hours
|
||||
if (normalized.minutes >= 60) {
|
||||
const additionalHours = Math.floor(normalized.minutes / 60);
|
||||
normalized.hours += additionalHours;
|
||||
normalized.minutes %= 60;
|
||||
}
|
||||
|
||||
// Convert hours to days if 24 or more
|
||||
if (normalized.hours >= 24) {
|
||||
const additionalDays = Math.floor(normalized.hours / 24);
|
||||
normalized.days += additionalDays;
|
||||
normalized.hours %= 24;
|
||||
}
|
||||
|
||||
const timeUnits: { key: TimeUnit; value: number; label: string }[] = [
|
||||
{ key: 'years', value: normalized.years, label: 'year' },
|
||||
{ key: 'months', value: normalized.months, label: 'month' },
|
||||
{ key: 'days', value: normalized.days, label: 'day' },
|
||||
{ key: 'hours', value: normalized.hours, label: 'hour' },
|
||||
{ key: 'minutes', value: normalized.minutes, label: 'minute' },
|
||||
{ key: 'seconds', value: normalized.seconds, label: 'second' },
|
||||
{
|
||||
key: 'milliseconds',
|
||||
value: normalized.milliseconds,
|
||||
label: 'millisecond'
|
||||
}
|
||||
];
|
||||
|
||||
const parts = timeUnits
|
||||
.filter(({ key }) => includeUnits.includes(key))
|
||||
.map(({ key, value, divisor }) => {
|
||||
const remaining = divisor ? value % divisor : value;
|
||||
return remaining > 0 ? `${remaining} ${key}` : '';
|
||||
.map(({ value, label }) => {
|
||||
if (value === 0) return '';
|
||||
return `${value} ${label}${value === 1 ? '' : 's'}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
if (includeUnits.includes('milliseconds')) {
|
||||
return `${difference.milliseconds} millisecond${
|
||||
difference.milliseconds === 1 ? '' : 's'
|
||||
}`;
|
||||
}
|
||||
return '0 seconds';
|
||||
return '0 minutes';
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
@@ -85,45 +177,49 @@ export const getTimeWithTimezone = (
|
||||
timeString: string,
|
||||
timezone: string
|
||||
): Date => {
|
||||
// Combine date and time
|
||||
const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing
|
||||
const utcDate = new Date(dateTimeString);
|
||||
|
||||
if (isNaN(utcDate.getTime())) {
|
||||
throw new Error('Invalid date or time format');
|
||||
}
|
||||
|
||||
// If timezone is "local", return the local date
|
||||
if (timezone === 'local') {
|
||||
return utcDate;
|
||||
const dateTimeString = `${dateString}T${timeString}`;
|
||||
return dayjs(dateTimeString).toDate();
|
||||
}
|
||||
|
||||
// Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4")
|
||||
// Check if the timezone is a known abbreviation
|
||||
if (tzMap[timezone]) {
|
||||
const dateTimeString = `${dateString} ${timeString}`;
|
||||
return dayjs
|
||||
.tz(dateTimeString, 'YYYY-MM-DD HH:mm', tzMap[timezone])
|
||||
.toDate();
|
||||
}
|
||||
|
||||
// Handle GMT+/- format
|
||||
const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid timezone format');
|
||||
}
|
||||
|
||||
const dateTimeString = `${dateString}T${timeString}Z`;
|
||||
const utcDate = dayjs.utc(dateTimeString);
|
||||
|
||||
if (!utcDate.isValid()) {
|
||||
throw new Error('Invalid date or time format');
|
||||
}
|
||||
|
||||
const offsetHours = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
const totalOffsetMinutes =
|
||||
offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes);
|
||||
|
||||
// Adjust the UTC date by the timezone offset
|
||||
return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000);
|
||||
return utcDate.subtract(totalOffsetMinutes, 'minute').toDate();
|
||||
};
|
||||
|
||||
// Helper function to format time based on largest unit
|
||||
export const formatTimeWithLargestUnit = (
|
||||
difference: TimeDifference,
|
||||
largestUnit: TimeUnit
|
||||
): string => {
|
||||
const largestUnitIndex = unitHierarchy.indexOf(largestUnit);
|
||||
const unitsToInclude = unitHierarchy.slice(largestUnitIndex);
|
||||
|
||||
// Preserve only whole values, do not apply fractional conversions
|
||||
const adjustedDifference: TimeDifference = { ...difference };
|
||||
|
||||
return formatTimeDifference(adjustedDifference, unitsToInclude);
|
||||
const unitsToInclude = unitHierarchy.slice(
|
||||
largestUnitIndex,
|
||||
unitHierarchy.length // Include milliseconds if it's the largest unit requested
|
||||
);
|
||||
return formatTimeDifference(difference, unitsToInclude);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user