mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 14:09:31 +02:00
Merge branch 'main' of https://github.com/ARRY7686/omni-tools
This commit is contained in:
82
.idea/workspace.xml
generated
82
.idea/workspace.xml
generated
@@ -4,10 +4,10 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="fix: misc">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: remove unnecessary prop">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/protect-pdf/service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/options/TextareaWithDesc.tsx" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/insert-csv-columns/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/insert-csv-columns/index.tsx" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<option name="PUSH_AUTO_UPDATE" value="true" />
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="fork/rohit267/feat/pdf-merge" />
|
||||
<entry key="$PROJECT_DIR$" value="main" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
@@ -142,6 +142,20 @@
|
||||
"number": 117
|
||||
},
|
||||
"lastSeen": 1747929835864
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6XY-mZ",
|
||||
"number": 119
|
||||
},
|
||||
"lastSeen": 1748028108508
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6Xdz4n",
|
||||
"number": 120
|
||||
},
|
||||
"lastSeen": 1748282672214
|
||||
}
|
||||
]
|
||||
}]]></component>
|
||||
@@ -197,7 +211,7 @@
|
||||
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
|
||||
"Vitest.replaceText function.executor": "Run",
|
||||
"Vitest.timeBetweenDates.executor": "Run",
|
||||
"git-widget-placeholder": "#117 on fork/nevolodia/flip-video",
|
||||
"git-widget-placeholder": "#120 on chesterkxng",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
|
||||
@@ -433,30 +447,8 @@
|
||||
<workItem from="1747171958176" duration="1105000" />
|
||||
<workItem from="1747217211469" duration="4000" />
|
||||
<workItem from="1747929815472" duration="843000" />
|
||||
</task>
|
||||
<task id="LOCAL-00147" summary="chore: update meta">
|
||||
<option name="closed" value="true" />
|
||||
<created>1741419527557</created>
|
||||
<option name="number" value="00147" />
|
||||
<option name="presentableId" value="LOCAL-00147" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1741419527557</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00148" summary="feat: change pgn opacity">
|
||||
<option name="closed" value="true" />
|
||||
<created>1741423117739</created>
|
||||
<option name="number" value="00148" />
|
||||
<option name="presentableId" value="LOCAL-00148" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1741423117739</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00149" summary="feat: change pgn opacity">
|
||||
<option name="closed" value="true" />
|
||||
<created>1741423587662</created>
|
||||
<option name="number" value="00149" />
|
||||
<option name="presentableId" value="LOCAL-00149" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1741423587662</updated>
|
||||
<workItem from="1748026506667" duration="2536000" />
|
||||
<workItem from="1748282636141" duration="478000" />
|
||||
</task>
|
||||
<task id="LOCAL-00150" summary="feat: crop png">
|
||||
<option name="closed" value="true" />
|
||||
@@ -826,7 +818,31 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1747172914927</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="196" />
|
||||
<task id="LOCAL-00196" summary="chore: revert create-tool.mjs">
|
||||
<option name="closed" value="true" />
|
||||
<created>1748027090253</created>
|
||||
<option name="number" value="00196" />
|
||||
<option name="presentableId" value="LOCAL-00196" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1748027090253</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00197" summary="fix: misc">
|
||||
<option name="closed" value="true" />
|
||||
<created>1748027889103</created>
|
||||
<option name="number" value="00197" />
|
||||
<option name="presentableId" value="LOCAL-00197" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1748027889103</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00198" summary="chore: remove unnecessary prop">
|
||||
<option name="closed" value="true" />
|
||||
<created>1748028055669</created>
|
||||
<option name="number" value="00198" />
|
||||
<option name="presentableId" value="LOCAL-00198" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1748028055669</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="199" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -873,8 +889,6 @@
|
||||
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||
<option name="CHECK_NEW_TODO" value="false" />
|
||||
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
|
||||
<MESSAGE value="refactor: time between dates" />
|
||||
<MESSAGE value="fix: typos" />
|
||||
<MESSAGE value="feat: compress video" />
|
||||
<MESSAGE value="chore: compress video icon" />
|
||||
<MESSAGE value="fix: gif speed" />
|
||||
@@ -897,8 +911,10 @@
|
||||
<MESSAGE value="fix: add mkv to supported videos" />
|
||||
<MESSAGE value="feat: drag and drop" />
|
||||
<MESSAGE value="Merge branch 'feat/pdf-merge' of git-rohit:rohit267/omni-tools into feat/pdf-merge" />
|
||||
<MESSAGE value="chore: revert create-tool.mjs" />
|
||||
<MESSAGE value="fix: misc" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="fix: misc" />
|
||||
<MESSAGE value="chore: remove unnecessary prop" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="chore: remove unnecessary prop" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
@@ -100,12 +100,9 @@ const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
onClick={() => navigate('/')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
src={logo}
|
||||
width={isMobile ? '80px' : '150px'}
|
||||
/>
|
||||
<a href="/">
|
||||
<img src={logo} width={isMobile ? '80px' : '150px'} />
|
||||
</a>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<IconButton
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { tool as insertCsvColumns } from './insert-csv-columns/meta';
|
||||
import { tool as transposeCsv } from './transpose-csv/meta';
|
||||
import { tool as findIncompleteCsvRecords } from './find-incomplete-csv-records/meta';
|
||||
import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta';
|
||||
@@ -17,5 +18,6 @@ export const csvTools = [
|
||||
csvToYaml,
|
||||
ChangeCsvDelimiter,
|
||||
findIncompleteCsvRecords,
|
||||
transposeCsv
|
||||
transposeCsv,
|
||||
insertCsvColumns
|
||||
];
|
||||
|
284
src/pages/tools/csv/insert-csv-columns/index.tsx
Normal file
284
src/pages/tools/csv/insert-csv-columns/index.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
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 { getCsvHeaders } from '@utils/csv';
|
||||
import { InitialValuesType } from './types';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
csvToInsert: '',
|
||||
commentCharacter: '#',
|
||||
separator: ',',
|
||||
quoteChar: '"',
|
||||
insertingPosition: 'append',
|
||||
customFill: false,
|
||||
customFillValue: '',
|
||||
customPostionOptions: 'headerName',
|
||||
headerName: '',
|
||||
rowNumber: 1
|
||||
};
|
||||
|
||||
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Add One Column to a CSV File',
|
||||
description:
|
||||
'In this example, we insert a column with the title "city" into a CSV file that already contains two other columns with titles "name" and "age". The new column consists of three values: "city", "dallas", and "houston", corresponding to the height of the input CSV data. The value "city" is the header value (appearing on the first row) and values "dallas" and "houston" are data values (appearing on rows two and three). We specify the position of the new column by an ordinal number and set it to 1 in the options. This value indicates that the new "city" column should be placed after the first column.',
|
||||
sampleText: `name,age
|
||||
john,25
|
||||
emma,22`,
|
||||
sampleResult: `name,city,age
|
||||
john,dallas,25
|
||||
emma,houston,22`,
|
||||
sampleOptions: {
|
||||
csvToInsert: `city
|
||||
dallas
|
||||
houston`,
|
||||
commentCharacter: '#',
|
||||
separator: ',',
|
||||
quoteChar: '"',
|
||||
insertingPosition: 'custom',
|
||||
customFill: true,
|
||||
customFillValue: 'k',
|
||||
customPostionOptions: 'rowNumber',
|
||||
headerName: '',
|
||||
rowNumber: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Append Multiple columns by header Name',
|
||||
description:
|
||||
'In this example, we append two data columns to the end of CSV data. The input CSV has data about cars, including the "Brand" and "Model" of the car. We now add two more columns at the end: "Year" and "Price". To do this, we enter these two data columns in the comma-separated format in the "New Column" option, and to quickly add the new columns to the end of the CSV, then we specify the name of the header they should be put after.',
|
||||
sampleText: `Brand,Model
|
||||
Toyota,Camry
|
||||
Ford,Mustang
|
||||
Honda,Accord
|
||||
Chevrolet,Malibu`,
|
||||
sampleResult: `Brand,Model,Year,Price
|
||||
Toyota,Camry,2022,25000
|
||||
Ford,Mustang,2021,35000
|
||||
Honda,Accord,2022,27000
|
||||
Chevrolet,Malibu,2021,28000`,
|
||||
sampleOptions: {
|
||||
csvToInsert: `Year,Price
|
||||
2022,25000
|
||||
2021,35000
|
||||
2022,27000
|
||||
2021,28000`,
|
||||
commentCharacter: '#',
|
||||
separator: ',',
|
||||
quoteChar: '"',
|
||||
insertingPosition: 'custom',
|
||||
customFill: false,
|
||||
customFillValue: 'x',
|
||||
customPostionOptions: 'headerName',
|
||||
headerName: 'Model',
|
||||
rowNumber: 1
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Append Multiple columns',
|
||||
description:
|
||||
'In this example, we append two data columns to the end of CSV data. The input CSV has data about cars, including the "Brand" and "Model" of the car. We now add two more columns at the end: "Year" and "Price". To do this, we enter these two data columns in the comma-separated format in the "New Column" option, and to quickly add the new columns to the end of the CSV, then we select append.',
|
||||
sampleText: `Brand,Model
|
||||
Toyota,Camry
|
||||
Ford,Mustang
|
||||
Honda,Accord
|
||||
Chevrolet,Malibu`,
|
||||
sampleResult: `Brand,Model,Year,Price
|
||||
Toyota,Camry,2022,25000
|
||||
Ford,Mustang,2021,35000
|
||||
Honda,Accord,2022,27000
|
||||
Chevrolet,Malibu,2021,28000`,
|
||||
sampleOptions: {
|
||||
csvToInsert: `Year,Price
|
||||
2022,25000
|
||||
2021,35000
|
||||
2022,27000
|
||||
2021,28000`,
|
||||
commentCharacter: '#',
|
||||
separator: ',',
|
||||
quoteChar: '"',
|
||||
insertingPosition: 'append',
|
||||
customFill: false,
|
||||
customFillValue: 'x',
|
||||
customPostionOptions: 'rowNumber',
|
||||
headerName: '',
|
||||
rowNumber: 1
|
||||
}
|
||||
}
|
||||
];
|
||||
export default function InsertCsvColumns({
|
||||
title,
|
||||
longDescription
|
||||
}: ToolComponentProps) {
|
||||
const [input, setInput] = useState<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
const compute = (values: InitialValuesType, input: string) => {
|
||||
setResult(main(input, values));
|
||||
};
|
||||
|
||||
const headers = getCsvHeaders(input);
|
||||
const headerOptions =
|
||||
headers.length > 0
|
||||
? headers.map((item) => ({
|
||||
label: `${item}`,
|
||||
value: item
|
||||
}))
|
||||
: [];
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'CSV to insert',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
multiline
|
||||
rows={3}
|
||||
value={values.csvToInsert}
|
||||
onOwnChange={(val) => updateField('csvToInsert', val)}
|
||||
title="CSV separator"
|
||||
description={`Enter one or more columns you want to insert into the CSV.
|
||||
the character used to delimit columns has to be the same with the one in the CSV input file.
|
||||
Ps: Blank lines will be ignored`}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'CSV Options',
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
value={values.separator}
|
||||
onOwnChange={(val) => updateField('separator', val)}
|
||||
description={
|
||||
'Enter the character used to delimit columns in the CSV input file.'
|
||||
}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
value={values.quoteChar}
|
||||
onOwnChange={(val) => updateField('quoteChar', 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.'
|
||||
}
|
||||
/>
|
||||
<SelectWithDesc
|
||||
selected={values.customFill}
|
||||
options={[
|
||||
{ label: 'Fill With Empty Values', value: false },
|
||||
{ label: 'Fill With Customs Values', value: true }
|
||||
]}
|
||||
onChange={(value) => {
|
||||
updateField('customFill', value);
|
||||
if (!value) {
|
||||
updateField('customFillValue', ''); // Reset custom fill value
|
||||
}
|
||||
}}
|
||||
description={
|
||||
'If the input CSV file is incomplete (missing values), then add empty fields or custom symbols to records to make a well-formed CSV?'
|
||||
}
|
||||
/>
|
||||
{values.customFill && (
|
||||
<TextFieldWithDesc
|
||||
value={values.customFillValue}
|
||||
onOwnChange={(val) => updateField('customFillValue', val)}
|
||||
description={
|
||||
'Use this custom value to fill in missing fields. (Works only with "Custom Values" mode above.)'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Position Options',
|
||||
component: (
|
||||
<Box>
|
||||
<SelectWithDesc
|
||||
selected={values.insertingPosition}
|
||||
options={[
|
||||
{ label: 'Prepend columns', value: 'prepend' },
|
||||
{ label: 'Append columns', value: 'append' },
|
||||
{ label: 'Custom position', value: 'custom' }
|
||||
]}
|
||||
onChange={(value) => updateField('insertingPosition', value)}
|
||||
description={'Specify where to insert the columns in the CSV file.'}
|
||||
/>
|
||||
|
||||
{values.insertingPosition === 'custom' && (
|
||||
<SelectWithDesc
|
||||
selected={values.customPostionOptions}
|
||||
options={[
|
||||
{ label: 'Header name', value: 'headerName' },
|
||||
{ label: 'Position ', value: 'rowNumber' }
|
||||
]}
|
||||
onChange={(value) => updateField('customPostionOptions', value)}
|
||||
description={
|
||||
'Select the method to insert the columns in the CSV file.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.insertingPosition === 'custom' &&
|
||||
values.customPostionOptions === 'headerName' && (
|
||||
<SelectWithDesc
|
||||
selected={values.headerName}
|
||||
options={headerOptions}
|
||||
onChange={(value) => updateField('headerName', value)}
|
||||
description={
|
||||
'Header of the column you want to insert columns after.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.insertingPosition === 'custom' &&
|
||||
values.customPostionOptions === 'rowNumber' && (
|
||||
<TextFieldWithDesc
|
||||
value={values.rowNumber}
|
||||
onOwnChange={(val) => updateField('rowNumber', Number(val))}
|
||||
inputProps={{ min: 1, max: headers.length }}
|
||||
type="number"
|
||||
description={
|
||||
'Number of the column you want to insert columns after.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolTextInput value={input} title="Input CSV" 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { main } from './service';
|
||||
import type { InitialValuesType } from './types';
|
||||
|
||||
describe('main function', () => {
|
||||
const baseOptions: Omit<InitialValuesType, 'csvToInsert'> = {
|
||||
commentCharacter: '#',
|
||||
separator: ',',
|
||||
quoteChar: '"',
|
||||
insertingPosition: 'append',
|
||||
customFill: false,
|
||||
customFillValue: '',
|
||||
customPostionOptions: 'headerName',
|
||||
headerName: 'age',
|
||||
rowNumber: 1
|
||||
};
|
||||
|
||||
const originalCsv = `name,age\nAlice,30\nBob,25`;
|
||||
|
||||
it('should return empty string if input or csvToInsert is empty', () => {
|
||||
expect(main('', { ...baseOptions, csvToInsert: '' })).toBe('');
|
||||
expect(main(originalCsv, { ...baseOptions, csvToInsert: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('should append columns at the end', () => {
|
||||
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
|
||||
const result = main(originalCsv, {
|
||||
...baseOptions,
|
||||
insertingPosition: 'append',
|
||||
csvToInsert
|
||||
});
|
||||
expect(result).toBe(
|
||||
'name,age,email\nAlice,30,alice@mail.com\nBob,25,bob@mail.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should prepend columns at the beginning', () => {
|
||||
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
|
||||
const result = main(originalCsv, {
|
||||
...baseOptions,
|
||||
insertingPosition: 'prepend',
|
||||
csvToInsert
|
||||
});
|
||||
expect(result).toBe(
|
||||
'email,name,age\nalice@mail.com,Alice,30\nbob@mail.com,Bob,25'
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert columns after a header name', () => {
|
||||
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
|
||||
const result = main(originalCsv, {
|
||||
...baseOptions,
|
||||
insertingPosition: 'custom',
|
||||
customPostionOptions: 'headerName',
|
||||
headerName: 'name',
|
||||
csvToInsert
|
||||
});
|
||||
expect(result).toBe(
|
||||
'name,email,age\nAlice,alice@mail.com,30\nBob,bob@mail.com,25'
|
||||
);
|
||||
});
|
||||
|
||||
it('should insert columns after a row number (column index)', () => {
|
||||
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
|
||||
const result = main(originalCsv, {
|
||||
...baseOptions,
|
||||
insertingPosition: 'custom',
|
||||
customPostionOptions: 'rowNumber',
|
||||
rowNumber: 0,
|
||||
csvToInsert
|
||||
});
|
||||
expect(result).toBe(
|
||||
'email,name,age\nalice@mail.com,Alice,30\nbob@mail.com,Bob,25'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing values and fill with empty string by default', () => {
|
||||
const csv = `name\nAlice\nBob`;
|
||||
const csvToInsert = `email\nalice@mail.com\n`; // second row is missing
|
||||
const result = main(csv, {
|
||||
...baseOptions,
|
||||
insertingPosition: 'append',
|
||||
csvToInsert
|
||||
});
|
||||
expect(result).toBe('name,email\nAlice,alice@mail.com\nBob,');
|
||||
});
|
||||
});
|
15
src/pages/tools/csv/insert-csv-columns/meta.ts
Normal file
15
src/pages/tools/csv/insert-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: 'Insert CSV columns',
|
||||
path: 'insert-csv-columns',
|
||||
icon: 'hugeicons:column-insert',
|
||||
description:
|
||||
'Just upload your CSV file in the form below, paste the new column in the options, and it will automatically get inserted in your CSV. In the tool options, you can also specify more than one column to insert, set the insertion position, and optionally skip the empty and comment lines.',
|
||||
shortDescription:
|
||||
'Quickly insert one or more new columns anywhere in a CSV file.',
|
||||
keywords: ['insert', 'csv', 'columns', 'append', 'prepend'],
|
||||
longDescription: '',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
81
src/pages/tools/csv/insert-csv-columns/service.ts
Normal file
81
src/pages/tools/csv/insert-csv-columns/service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { InitialValuesType } from './types';
|
||||
import { splitCsv } from '@utils/csv';
|
||||
import { transpose, normalizeAndFill } from '@utils/array';
|
||||
|
||||
export function main(input: string, options: InitialValuesType): string {
|
||||
if (!input || !options.csvToInsert) return '';
|
||||
|
||||
// Parse input CSV and insert CSV
|
||||
const inputRows = splitCsv(
|
||||
input,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
true,
|
||||
options.separator,
|
||||
options.quoteChar
|
||||
);
|
||||
|
||||
const filledRows = options.customFill
|
||||
? normalizeAndFill(inputRows, options.customFillValue)
|
||||
: normalizeAndFill(inputRows, '');
|
||||
|
||||
let columns = transpose(filledRows);
|
||||
|
||||
const csvToInsertRows = splitCsv(
|
||||
options.csvToInsert,
|
||||
true,
|
||||
options.commentCharacter,
|
||||
true,
|
||||
options.separator,
|
||||
options.quoteChar
|
||||
);
|
||||
|
||||
const filledCsvToInsertRows = options.customFill
|
||||
? normalizeAndFill(csvToInsertRows, options.customFillValue)
|
||||
: normalizeAndFill(csvToInsertRows, '');
|
||||
|
||||
const columnsToInsert = transpose(filledCsvToInsertRows);
|
||||
|
||||
switch (options.insertingPosition) {
|
||||
case 'prepend':
|
||||
columns = [...columnsToInsert, ...columns];
|
||||
break;
|
||||
|
||||
case 'append':
|
||||
columns = [...columns, ...columnsToInsert];
|
||||
break;
|
||||
|
||||
case 'custom':
|
||||
if (options.customPostionOptions === 'headerName') {
|
||||
const headerName = options.headerName;
|
||||
const index = filledRows[0].indexOf(headerName);
|
||||
if (index !== -1) {
|
||||
columns = [
|
||||
...columns.slice(0, index + 1),
|
||||
...columnsToInsert,
|
||||
...columns.slice(index + 1)
|
||||
];
|
||||
} // else: keep original columns
|
||||
} else if (options.customPostionOptions === 'rowNumber') {
|
||||
const index = options.rowNumber;
|
||||
if (index >= 0 && index <= columns.length) {
|
||||
columns = [
|
||||
...columns.slice(0, index),
|
||||
...columnsToInsert,
|
||||
...columns.slice(index)
|
||||
];
|
||||
} // else: keep original columns
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// no-op, keep original columns
|
||||
break;
|
||||
}
|
||||
|
||||
// Transpose back to rows
|
||||
const normalizedColumns = normalizeAndFill(columns, options.customFillValue);
|
||||
const finalRows = transpose(normalizedColumns);
|
||||
|
||||
return finalRows.map((row) => row.join(options.separator)).join('\n');
|
||||
}
|
15
src/pages/tools/csv/insert-csv-columns/types.ts
Normal file
15
src/pages/tools/csv/insert-csv-columns/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type insertingPosition = 'prepend' | 'append' | 'custom';
|
||||
export type customPostion = 'headerName' | 'rowNumber';
|
||||
|
||||
export type InitialValuesType = {
|
||||
csvToInsert: string;
|
||||
separator: string;
|
||||
quoteChar: string;
|
||||
commentCharacter: string;
|
||||
customFill: boolean;
|
||||
customFillValue: string;
|
||||
insertingPosition: insertingPosition;
|
||||
customPostionOptions: customPostion;
|
||||
headerName: string;
|
||||
rowNumber: number;
|
||||
};
|
219
src/pages/tools/video/crop-video/index.tsx
Normal file
219
src/pages/tools/video/crop-video/index.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Box, TextField, Typography, Alert } from '@mui/material';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||
import { debounce } from 'lodash';
|
||||
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||
import { cropVideo, getVideoDimensions } from './service';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const initialValues: InitialValuesType = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100
|
||||
};
|
||||
|
||||
export default function CropVideo({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [videoDimensions, setVideoDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [processingError, setProcessingError] = useState<string>('');
|
||||
|
||||
const validateDimensions = (values: InitialValuesType): string => {
|
||||
if (!videoDimensions) return '';
|
||||
|
||||
if (values.x < 0 || values.y < 0) {
|
||||
return 'X and Y coordinates must be non-negative';
|
||||
}
|
||||
|
||||
if (values.width <= 0 || values.height <= 0) {
|
||||
return 'Width and height must be positive';
|
||||
}
|
||||
|
||||
if (values.x + values.width > videoDimensions.width) {
|
||||
return `Crop area extends beyond video width (${videoDimensions.width}px)`;
|
||||
}
|
||||
|
||||
if (values.y + values.height > videoDimensions.height) {
|
||||
return `Crop area extends beyond video height (${videoDimensions.height}px)`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const compute = async (
|
||||
optionsValues: InitialValuesType,
|
||||
input: File | null
|
||||
) => {
|
||||
if (!input) return;
|
||||
|
||||
const error = validateDimensions(optionsValues);
|
||||
if (error) {
|
||||
setProcessingError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessingError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const croppedFile = await cropVideo(input, optionsValues);
|
||||
setResult(croppedFile);
|
||||
} catch (error) {
|
||||
console.error('Error cropping video:', error);
|
||||
setProcessingError(
|
||||
'Error cropping video. Please check parameters and video file.'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 2 seconds to avoid starting job half way through
|
||||
const debouncedCompute = useCallback(debounce(compute, 2000), [
|
||||
videoDimensions
|
||||
]);
|
||||
|
||||
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Video Information',
|
||||
component: (
|
||||
<Box>
|
||||
{videoDimensions ? (
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Video dimensions: {videoDimensions.width} ×{' '}
|
||||
{videoDimensions.height} pixels
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Load a video to see dimensions
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Crop Coordinates',
|
||||
component: (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{processingError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{processingError}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="X (left)"
|
||||
type="number"
|
||||
value={values.x}
|
||||
onChange={(e) => updateField('x', parseInt(e.target.value) || 0)}
|
||||
size="small"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Y (top)"
|
||||
type="number"
|
||||
value={values.y}
|
||||
onChange={(e) => updateField('y', parseInt(e.target.value) || 0)}
|
||||
size="small"
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
label="Width"
|
||||
type="number"
|
||||
value={values.width}
|
||||
onChange={(e) =>
|
||||
updateField('width', parseInt(e.target.value) || 0)
|
||||
}
|
||||
size="small"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Height"
|
||||
type="number"
|
||||
value={values.height}
|
||||
onChange={(e) =>
|
||||
updateField('height', parseInt(e.target.value) || 0)
|
||||
}
|
||||
size="small"
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
renderCustomInput={(values, setFieldValue) => (
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={(video) => {
|
||||
if (video) {
|
||||
getVideoDimensions(video)
|
||||
.then((dimensions) => {
|
||||
const newOptions: InitialValuesType = {
|
||||
x: dimensions.width / 4,
|
||||
y: dimensions.height / 4,
|
||||
width: dimensions.width / 2,
|
||||
height: dimensions.height / 2
|
||||
};
|
||||
setFieldValue('x', newOptions.x);
|
||||
setFieldValue('y', newOptions.y);
|
||||
setFieldValue('width', newOptions.width);
|
||||
setFieldValue('height', newOptions.height);
|
||||
|
||||
setVideoDimensions(dimensions);
|
||||
setProcessingError('');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error getting video dimensions:', error);
|
||||
setProcessingError('Failed to load video dimensions');
|
||||
});
|
||||
} else {
|
||||
setVideoDimensions(null);
|
||||
setProcessingError('');
|
||||
}
|
||||
setInput(video);
|
||||
}}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
)}
|
||||
resultComponent={
|
||||
loading ? (
|
||||
<ToolFileResult
|
||||
title={'Cropping Video'}
|
||||
value={null}
|
||||
loading={true}
|
||||
extension={''}
|
||||
/>
|
||||
) : (
|
||||
<ToolFileResult
|
||||
title={'Cropped Video'}
|
||||
value={result}
|
||||
extension={'mp4'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={debouncedCompute}
|
||||
setInput={setInput}
|
||||
/>
|
||||
);
|
||||
}
|
14
src/pages/tools/video/crop-video/meta.ts
Normal file
14
src/pages/tools/video/crop-video/meta.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Crop video',
|
||||
path: 'crop-video',
|
||||
icon: 'mdi:crop',
|
||||
description: 'Crop a video by specifying coordinates and dimensions',
|
||||
shortDescription: 'Crop video to specific area',
|
||||
keywords: ['crop', 'video', 'trim', 'cut', 'resize'],
|
||||
longDescription:
|
||||
'Remove unwanted parts from the edges of your video by cropping it to a specific rectangular area. Define the starting coordinates (X, Y) and the width and height of the area you want to keep.',
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
67
src/pages/tools/video/crop-video/service.ts
Normal file
67
src/pages/tools/video/crop-video/service.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
import { InitialValuesType } from './types';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
export async function getVideoDimensions(
|
||||
file: File
|
||||
): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
});
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load video metadata'));
|
||||
};
|
||||
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function cropVideo(
|
||||
input: File,
|
||||
options: InitialValuesType
|
||||
): Promise<File> {
|
||||
if (!ffmpeg.loaded) {
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
}
|
||||
|
||||
const inputName = 'input.mp4';
|
||||
const outputName = 'output.mp4';
|
||||
await ffmpeg.writeFile(inputName, await fetchFile(input));
|
||||
|
||||
const args = [];
|
||||
|
||||
if (options.width <= 0 || options.height <= 0) {
|
||||
throw new Error('Width and height must be positive');
|
||||
}
|
||||
|
||||
args.push('-i', inputName);
|
||||
args.push(
|
||||
'-vf',
|
||||
`crop=${options.width}:${options.height}:${options.x}:${options.y}`
|
||||
);
|
||||
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
|
||||
|
||||
await ffmpeg.exec(args);
|
||||
|
||||
const croppedData = await ffmpeg.readFile(outputName);
|
||||
return await new File(
|
||||
[new Blob([croppedData], { type: 'video/mp4' })],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_cropped.mp4`,
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
}
|
6
src/pages/tools/video/crop-video/types.ts
Normal file
6
src/pages/tools/video/crop-video/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type InitialValuesType = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
@@ -7,6 +7,7 @@ import { tool as rotateVideo } from './rotate/meta';
|
||||
import { tool as compressVideo } from './compress/meta';
|
||||
import { tool as loopVideo } from './loop/meta';
|
||||
import { tool as flipVideo } from './flip/meta';
|
||||
import { tool as cropVideo } from './crop-video/meta';
|
||||
import { tool as changeSpeed } from './change-speed/meta';
|
||||
|
||||
export const videoTools = [
|
||||
@@ -16,5 +17,6 @@ export const videoTools = [
|
||||
compressVideo,
|
||||
loopVideo,
|
||||
flipVideo,
|
||||
cropVideo,
|
||||
changeSpeed
|
||||
];
|
||||
|
@@ -12,10 +12,17 @@ export function transpose<T>(matrix: T[][]): any[][] {
|
||||
* Normalize and fill a 2D array to ensure all rows have the same length.
|
||||
* @param {any[][]} matrix - The 2D array to normalize and fill.
|
||||
* @param {any} fillValue - The value to fill in for missing elements.
|
||||
* @param {number} desiredLength - The target length of the array. if given take it as maxLength.
|
||||
* @returns {any[][]} - The normalized and filled 2D array.
|
||||
* **/
|
||||
export function normalizeAndFill<T>(matrix: T[][], fillValue: T): T[][] {
|
||||
const maxLength = Math.max(...matrix.map((row) => row.length));
|
||||
export function normalizeAndFill<T>(
|
||||
matrix: T[][],
|
||||
fillValue: T,
|
||||
desiredLength?: number
|
||||
): T[][] {
|
||||
const maxLength = !desiredLength
|
||||
? Math.max(...matrix.map((row) => row.length))
|
||||
: desiredLength;
|
||||
return matrix.map((row) => {
|
||||
const filledRow = [...row];
|
||||
while (filledRow.length < maxLength) {
|
||||
|
Reference in New Issue
Block a user