mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 14:09:31 +02:00
Merge branch 'main' into logo-right-mouse-click-bug
This commit is contained in:
198
.idea/workspace.xml
generated
198
.idea/workspace.xml
generated
@@ -4,9 +4,10 @@
|
|||||||
<option name="autoReloadType" value="SELECTIVE" />
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<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$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/src/components/Navbar/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Navbar/index.tsx" 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>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
@@ -42,101 +43,122 @@
|
|||||||
"state": "OPEN"
|
"state": "OPEN"
|
||||||
}
|
}
|
||||||
}</component>
|
}</component>
|
||||||
<component name="GitHubPullRequestState">{
|
<component name="GitHubPullRequestState"><![CDATA[{
|
||||||
"prStates": [
|
"prStates": [
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts51PkS9",
|
"id": "PR_kwDOMJIfts51PkS9",
|
||||||
"number": 22
|
"number": 22
|
||||||
},
|
},
|
||||||
"lastSeen": 1741207144695
|
"lastSeen": 1741207144695
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6NiNYl",
|
"id": "PR_kwDOMJIfts6NiNYl",
|
||||||
"number": 32
|
"number": 32
|
||||||
},
|
},
|
||||||
"lastSeen": 1741209723869
|
"lastSeen": 1741209723869
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6Nheyd",
|
"id": "PR_kwDOMJIfts6Nheyd",
|
||||||
"number": 31
|
"number": 31
|
||||||
},
|
},
|
||||||
"lastSeen": 1741213371410
|
"lastSeen": 1741213371410
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6NmRBs",
|
"id": "PR_kwDOMJIfts6NmRBs",
|
||||||
"number": 33
|
"number": 33
|
||||||
},
|
},
|
||||||
"lastSeen": 1741282429036
|
"lastSeen": 1741282429036
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts5zyFTs",
|
"id": "PR_kwDOMJIfts5zyFTs",
|
||||||
"number": 15
|
"number": 15
|
||||||
},
|
},
|
||||||
"lastSeen": 1741535540953
|
"lastSeen": 1741535540953
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6QQB3c",
|
"id": "PR_kwDOMJIfts6QQB3c",
|
||||||
"number": 59
|
"number": 59
|
||||||
},
|
},
|
||||||
"lastSeen": 1743018960900
|
"lastSeen": 1743018960900
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6QMPEg",
|
"id": "PR_kwDOMJIfts6QMPEg",
|
||||||
"number": 58
|
"number": 58
|
||||||
},
|
},
|
||||||
"lastSeen": 1743019452983
|
"lastSeen": 1743019452983
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6QZvRI",
|
"id": "PR_kwDOMJIfts6QZvRI",
|
||||||
"number": 61
|
"number": 61
|
||||||
},
|
},
|
||||||
"lastSeen": 1743103196866
|
"lastSeen": 1743103196866
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6QqPrQ",
|
"id": "PR_kwDOMJIfts6QqPrQ",
|
||||||
"number": 73
|
"number": 73
|
||||||
},
|
},
|
||||||
"lastSeen": 1743265865001
|
"lastSeen": 1743265865001
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6Qp5nI",
|
"id": "PR_kwDOMJIfts6Qp5nI",
|
||||||
"number": 72
|
"number": 72
|
||||||
},
|
},
|
||||||
"lastSeen": 1743338472110
|
"lastSeen": 1743338472110
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6QsjlS",
|
"id": "PR_kwDOMJIfts6QsjlS",
|
||||||
"number": 76
|
"number": 76
|
||||||
},
|
},
|
||||||
"lastSeen": 1743352150953
|
"lastSeen": 1743352150953
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6Q0JBe",
|
"id": "PR_kwDOMJIfts6Q0JBe",
|
||||||
"number": 82
|
"number": 82
|
||||||
},
|
},
|
||||||
"lastSeen": 1743470267269
|
"lastSeen": 1743470267269
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": {
|
"id": {
|
||||||
"id": "PR_kwDOMJIfts6UE9-x",
|
"id": "PR_kwDOMJIfts6UE9-x",
|
||||||
"number": 102
|
"number": 102
|
||||||
},
|
},
|
||||||
"lastSeen": 1747171977348
|
"lastSeen": 1747171977348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": {
|
||||||
|
"id": "PR_kwDOMJIfts6XPua_",
|
||||||
|
"number": 117
|
||||||
|
},
|
||||||
|
"lastSeen": 1747929835864
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": {
|
||||||
|
"id": "PR_kwDOMJIfts6XY-mZ",
|
||||||
|
"number": 119
|
||||||
|
},
|
||||||
|
"lastSeen": 1748028108508
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": {
|
||||||
|
"id": "PR_kwDOMJIfts6Xdz4n",
|
||||||
|
"number": 120
|
||||||
|
},
|
||||||
|
"lastSeen": 1748282672214
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}</component>
|
}]]></component>
|
||||||
<component name="GithubPullRequestsUISettings">{
|
<component name="GithubPullRequestsUISettings">{
|
||||||
"selectedUrlAndAccountId": {
|
"selectedUrlAndAccountId": {
|
||||||
"url": "https://github.com/iib0011/omni-tools.git",
|
"url": "https://github.com/iib0011/omni-tools.git",
|
||||||
@@ -189,7 +211,7 @@
|
|||||||
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
|
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
|
||||||
"Vitest.replaceText function.executor": "Run",
|
"Vitest.replaceText function.executor": "Run",
|
||||||
"Vitest.timeBetweenDates.executor": "Run",
|
"Vitest.timeBetweenDates.executor": "Run",
|
||||||
"git-widget-placeholder": "#125 on fork/nevolodia/logo-right-mouse-click-bug",
|
"git-widget-placeholder": "#120 on chesterkxng",
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
"ignore.virus.scanning.warn.message": "true",
|
||||||
"kotlin-language-version-configured": "true",
|
"kotlin-language-version-configured": "true",
|
||||||
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
|
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src",
|
||||||
@@ -423,30 +445,10 @@
|
|||||||
<workItem from="1745775228478" duration="1221000" />
|
<workItem from="1745775228478" duration="1221000" />
|
||||||
<workItem from="1745835676024" duration="68000" />
|
<workItem from="1745835676024" duration="68000" />
|
||||||
<workItem from="1747171958176" duration="1105000" />
|
<workItem from="1747171958176" duration="1105000" />
|
||||||
</task>
|
<workItem from="1747217211469" duration="4000" />
|
||||||
<task id="LOCAL-00147" summary="chore: update meta">
|
<workItem from="1747929815472" duration="843000" />
|
||||||
<option name="closed" value="true" />
|
<workItem from="1748026506667" duration="2536000" />
|
||||||
<created>1741419527557</created>
|
<workItem from="1748282636141" duration="478000" />
|
||||||
<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>
|
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00150" summary="feat: crop png">
|
<task id="LOCAL-00150" summary="feat: crop png">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -816,7 +818,31 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1747172914927</updated>
|
<updated>1747172914927</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
@@ -863,8 +889,6 @@
|
|||||||
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||||
<option name="CHECK_NEW_TODO" value="false" />
|
<option name="CHECK_NEW_TODO" value="false" />
|
||||||
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
|
<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="feat: compress video" />
|
||||||
<MESSAGE value="chore: compress video icon" />
|
<MESSAGE value="chore: compress video icon" />
|
||||||
<MESSAGE value="fix: gif speed" />
|
<MESSAGE value="fix: gif speed" />
|
||||||
@@ -887,8 +911,10 @@
|
|||||||
<MESSAGE value="fix: add mkv to supported videos" />
|
<MESSAGE value="fix: add mkv to supported videos" />
|
||||||
<MESSAGE value="feat: drag and drop" />
|
<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="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" />
|
<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>
|
||||||
<component name="XSLT-Support.FileAssociations.UIState">
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
<expand />
|
<expand />
|
||||||
|
@@ -33,18 +33,15 @@ export default function BaseFileInput({
|
|||||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
if (value) {
|
||||||
if (isArray(value)) {
|
try {
|
||||||
const objectUrl = createObjectURL(value[0]);
|
const objectUrl = createObjectURL(value);
|
||||||
setPreview(objectUrl);
|
setPreview(objectUrl);
|
||||||
|
|
||||||
return () => revokeObjectURL(objectUrl);
|
return () => revokeObjectURL(objectUrl);
|
||||||
} else {
|
} catch (error) {
|
||||||
setPreview(null);
|
console.error('Error previewing file:', error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else setPreview(null);
|
||||||
console.error('Error previewing file:', error);
|
|
||||||
}
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -70,11 +67,6 @@ export default function BaseFileInput({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
// @ts-ignore
|
|
||||||
onChange(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -216,11 +208,7 @@ export default function BaseFileInput({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<InputFooter
|
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
|
||||||
handleCopy={handleCopy}
|
|
||||||
handleImport={handleImportClick}
|
|
||||||
handleClear={handleClear}
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
@@ -2,7 +2,7 @@ import { defineTool } from '@tools/defineTool';
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
export const tool = defineTool('csv', {
|
export const tool = defineTool('csv', {
|
||||||
name: 'Change csv separator',
|
name: 'Change CSV separator',
|
||||||
path: 'change-csv-separator',
|
path: 'change-csv-separator',
|
||||||
icon: 'material-symbols:split-scene-rounded',
|
icon: 'material-symbols:split-scene-rounded',
|
||||||
description:
|
description:
|
||||||
|
@@ -4,12 +4,12 @@ import { lazy } from 'react';
|
|||||||
export const tool = defineTool('csv', {
|
export const tool = defineTool('csv', {
|
||||||
name: 'Convert CSV Rows to Columns',
|
name: 'Convert CSV Rows to Columns',
|
||||||
path: 'csv-rows-to-columns',
|
path: 'csv-rows-to-columns',
|
||||||
icon: 'carbon:transpose',
|
icon: 'fluent:text-arrow-down-right-column-24-filled',
|
||||||
description:
|
description:
|
||||||
'This tool converts rows of a CSV (Comma Separated Values) file into columns. It extracts the horizontal lines from the input CSV one by one, rotates them 90 degrees, and outputs them as vertical columns one after another, separated by commas.',
|
'This tool converts rows of a CSV (Comma Separated Values) file into columns. It extracts the horizontal lines from the input CSV one by one, rotates them 90 degrees, and outputs them as vertical columns one after another, separated by commas.',
|
||||||
longDescription:
|
longDescription:
|
||||||
'This tool converts rows of a CSV (Comma Separated Values) file into columns. For example, if the input CSV data has 6 rows, then the output will have 6 columns and the elements of the rows will be arranged from the top to bottom. In a well-formed CSV, the number of values in each row is the same. However, in cases when rows are missing fields, the program can fix them and you can choose from the available options: fill missing data with empty elements or replace missing data with custom elements, such as "missing", "?", or "x". During the conversion process, the tool also cleans the CSV file from unnecessary information, such as empty lines (these are lines without visible information) and comments. To help the tool correctly identify comments, in the options, you can specify the symbol at the beginning of a line that starts a comment. This symbol is typically a hash "#" or double slash "//". Csv-abulous!.',
|
'This tool converts rows of a CSV (Comma Separated Values) file into columns. For example, if the input CSV data has 6 rows, then the output will have 6 columns and the elements of the rows will be arranged from the top to bottom. In a well-formed CSV, the number of values in each row is the same. However, in cases when rows are missing fields, the program can fix them and you can choose from the available options: fill missing data with empty elements or replace missing data with custom elements, such as "missing", "?", or "x". During the conversion process, the tool also cleans the CSV file from unnecessary information, such as empty lines (these are lines without visible information) and comments. To help the tool correctly identify comments, in the options, you can specify the symbol at the beginning of a line that starts a comment. This symbol is typically a hash "#" or double slash "//". Csv-abulous!.',
|
||||||
shortDescription: 'Convert CSV data to JSON format',
|
shortDescription: 'Convert CSV rows to columns.',
|
||||||
keywords: ['csv', 'rows', 'columns', 'transpose'],
|
keywords: ['csv', 'rows', 'columns', 'transpose'],
|
||||||
component: lazy(() => import('./index'))
|
component: lazy(() => import('./index'))
|
||||||
});
|
});
|
||||||
|
@@ -7,7 +7,7 @@ export const tool = defineTool('csv', {
|
|||||||
icon: 'lets-icons:json-light',
|
icon: 'lets-icons:json-light',
|
||||||
description:
|
description:
|
||||||
'Convert CSV files to JSON format with customizable options for delimiters, quotes, and output formatting. Support for headers, comments, and dynamic type conversion.',
|
'Convert CSV files to JSON format with customizable options for delimiters, quotes, and output formatting. Support for headers, comments, and dynamic type conversion.',
|
||||||
shortDescription: 'Convert CSV data to JSON format',
|
shortDescription: 'Convert CSV data to JSON format.',
|
||||||
keywords: ['csv', 'json', 'convert', 'transform', 'parse'],
|
keywords: ['csv', 'json', 'convert', 'transform', 'parse'],
|
||||||
component: lazy(() => import('./index'))
|
component: lazy(() => import('./index'))
|
||||||
});
|
});
|
||||||
|
@@ -7,7 +7,7 @@ export const tool = defineTool('csv', {
|
|||||||
icon: 'codicon:keyboard-tab',
|
icon: 'codicon:keyboard-tab',
|
||||||
description:
|
description:
|
||||||
'Upload your CSV file in the form below and it will automatically get converted to a TSV file. In the tool options, you can customize the input CSV format – specify the field delimiter, quotation character, and comment symbol, as well as skip empty CSV lines, and choose whether to preserve CSV column headers.',
|
'Upload your CSV file in the form below and it will automatically get converted to a TSV file. In the tool options, you can customize the input CSV format – specify the field delimiter, quotation character, and comment symbol, as well as skip empty CSV lines, and choose whether to preserve CSV column headers.',
|
||||||
shortDescription: 'Convert CSV data to TSV format',
|
shortDescription: 'Convert CSV data to TSV format.',
|
||||||
longDescription:
|
longDescription:
|
||||||
'This tool transforms Comma Separated Values (CSV) data to Tab Separated Values (TSV) data. Both CSV and TSV are popular file formats for storing tabular data but they use different delimiters to separate values – CSV uses commas (","), while TSV uses tabs ("\t"). If we compare CSV files to TSV files, then CSV files are much harder to parse than TSV files because the values themselves may contain commas, so it is not always obvious where one field starts and ends without complicated parsing rules. TSV, on the other hand, uses just a tab symbol, which does not usually appear in data, so separating fields in TSV is as simple as splitting the input by the tab character. To convert CSV to TSV, simply input the CSV data in the input of this tool. In rare cases when a CSV file has a delimiter other than a comma, you can specify the current delimiter in the options of the tool. You can also specify the current quote character and the comment start character. Additionally, empty CSV lines can be skipped by activating the "Ignore Lines with No Data" option. If this option is off, then empty lines in the CSV are converted to empty TSV lines. The "Preserve Headers" option allows you to choose whether to process column headers of a CSV file. If the option is selected, then the resulting TSV file will include the first row of the input CSV file, which contains the column names. Alternatively, if the headers option is not selected, the first row will be skipped during the data conversion process. For the reverse conversion from TSV to CSV, you can use our Convert TSV to CSV tool. Csv-abulous!',
|
'This tool transforms Comma Separated Values (CSV) data to Tab Separated Values (TSV) data. Both CSV and TSV are popular file formats for storing tabular data but they use different delimiters to separate values – CSV uses commas (","), while TSV uses tabs ("\t"). If we compare CSV files to TSV files, then CSV files are much harder to parse than TSV files because the values themselves may contain commas, so it is not always obvious where one field starts and ends without complicated parsing rules. TSV, on the other hand, uses just a tab symbol, which does not usually appear in data, so separating fields in TSV is as simple as splitting the input by the tab character. To convert CSV to TSV, simply input the CSV data in the input of this tool. In rare cases when a CSV file has a delimiter other than a comma, you can specify the current delimiter in the options of the tool. You can also specify the current quote character and the comment start character. Additionally, empty CSV lines can be skipped by activating the "Ignore Lines with No Data" option. If this option is off, then empty lines in the CSV are converted to empty TSV lines. The "Preserve Headers" option allows you to choose whether to process column headers of a CSV file. If the option is selected, then the resulting TSV file will include the first row of the input CSV file, which contains the column names. Alternatively, if the headers option is not selected, the first row will be skipped during the data conversion process. For the reverse conversion from TSV to CSV, you can use our Convert TSV to CSV tool. Csv-abulous!',
|
||||||
keywords: ['csv', 'tsv', 'convert', 'transform', 'parse'],
|
keywords: ['csv', 'tsv', 'convert', 'transform', 'parse'],
|
||||||
|
@@ -6,7 +6,7 @@ export const tool = defineTool('csv', {
|
|||||||
path: 'csv-to-xml',
|
path: 'csv-to-xml',
|
||||||
icon: 'mdi-light:xml',
|
icon: 'mdi-light:xml',
|
||||||
description: 'Convert CSV files to XML format with customizable options.',
|
description: 'Convert CSV files to XML format with customizable options.',
|
||||||
shortDescription: 'Convert CSV data to XML format',
|
shortDescription: 'Convert CSV data to XML format.',
|
||||||
keywords: ['csv', 'xml', 'convert', 'transform', 'parse'],
|
keywords: ['csv', 'xml', 'convert', 'transform', 'parse'],
|
||||||
component: lazy(() => import('./index'))
|
component: lazy(() => import('./index'))
|
||||||
});
|
});
|
||||||
|
@@ -2,7 +2,7 @@ import { defineTool } from '@tools/defineTool';
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
export const tool = defineTool('csv', {
|
export const tool = defineTool('csv', {
|
||||||
name: 'Csv to yaml',
|
name: 'Convert CSV to YAML',
|
||||||
path: 'csv-to-yaml',
|
path: 'csv-to-yaml',
|
||||||
icon: 'nonicons:yaml-16',
|
icon: 'nonicons:yaml-16',
|
||||||
description:
|
description:
|
||||||
|
@@ -2,7 +2,7 @@ import { defineTool } from '@tools/defineTool';
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
export const tool = defineTool('csv', {
|
export const tool = defineTool('csv', {
|
||||||
name: 'Find incomplete csv records',
|
name: 'Find incomplete CSV records',
|
||||||
path: 'find-incomplete-csv-records',
|
path: 'find-incomplete-csv-records',
|
||||||
icon: 'tdesign:search-error',
|
icon: 'tdesign:search-error',
|
||||||
description:
|
description:
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
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 findIncompleteCsvRecords } from './find-incomplete-csv-records/meta';
|
||||||
import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta';
|
import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta';
|
||||||
import { tool as csvToYaml } from './csv-to-yaml/meta';
|
import { tool as csvToYaml } from './csv-to-yaml/meta';
|
||||||
@@ -15,5 +17,7 @@ export const csvTools = [
|
|||||||
swapCsvColumns,
|
swapCsvColumns,
|
||||||
csvToYaml,
|
csvToYaml,
|
||||||
ChangeCsvDelimiter,
|
ChangeCsvDelimiter,
|
||||||
findIncompleteCsvRecords
|
findIncompleteCsvRecords,
|
||||||
|
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;
|
||||||
|
};
|
@@ -7,7 +7,7 @@ export const tool = defineTool('csv', {
|
|||||||
icon: 'eva:swap-outline',
|
icon: 'eva:swap-outline',
|
||||||
description:
|
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.',
|
'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',
|
shortDescription: 'Reorder CSV columns.',
|
||||||
longDescription:
|
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!',
|
'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'],
|
keywords: ['csv', 'swap', 'columns'],
|
||||||
|
178
src/pages/tools/csv/transpose-csv/index.tsx
Normal file
178
src/pages/tools/csv/transpose-csv/index.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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 { transposeCSV } from './service';
|
||||||
|
import { InitialValuesType } from './types';
|
||||||
|
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||||
|
import SelectWithDesc from '@components/options/SelectWithDesc';
|
||||||
|
|
||||||
|
const initialValues: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
|
||||||
|
const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||||
|
{
|
||||||
|
title: 'Transpose a 2x3 CSV',
|
||||||
|
description:
|
||||||
|
'This example transposes a CSV with 2 rows and 3 columns. The tool splits the input data by the comma character, creating a 2 by 3 matrix. It then exchanges elements, turning columns into rows and vice versa. The output is a transposed CSV with flipped dimensions',
|
||||||
|
sampleText: `foo,bar,baz
|
||||||
|
val1,val2,val3`,
|
||||||
|
sampleResult: `foo,val1
|
||||||
|
bar,val2
|
||||||
|
baz,val3`,
|
||||||
|
sampleOptions: {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Transpose a Long CSV',
|
||||||
|
description:
|
||||||
|
'In this example, we flip a vertical single-column CSV file containing a list of our favorite fruits and their emojis. This single column is transformed into a single-row CSV file and the rows length matches the height of the original CSV.',
|
||||||
|
sampleText: `Tasty Fruit
|
||||||
|
🍑 peaches
|
||||||
|
🍒 cherries
|
||||||
|
🥝 kiwis
|
||||||
|
🍓 strawberries
|
||||||
|
🍎 apples
|
||||||
|
🍐 pears
|
||||||
|
🥭 mangos
|
||||||
|
🍍 pineapples
|
||||||
|
🍌 bananas
|
||||||
|
🍊 tangerines
|
||||||
|
🍉 watermelons
|
||||||
|
🍇 grapes`,
|
||||||
|
sampleResult: `fTasty Fruit,🍑 peaches,🍒 cherries,🥝 kiwis,🍓 strawberries,🍎 apples,🍐 pears,🥭 mangos,🍍 pineapples,🍌 bananas,🍊 tangerines,🍉 watermelons,🍇 grapes`,
|
||||||
|
sampleOptions: {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clean and Transpose CSV Data',
|
||||||
|
description:
|
||||||
|
'In this example, we perform three tasks simultaneously: transpose a CSV file, remove comments and empty lines, and fix missing data. The transposition operation is the same as flipping a matrix across its diagonal and it is done automatically by the program. Additionally, the program automatically removes all empty lines as they cannot be transposed. The comments are removed by specifying the "#" symbol in the options. The program also fixes missing data using a custom bullet symbol "•", which is specified in the options.',
|
||||||
|
sampleText: `Fish Type,Color,Habitat
|
||||||
|
Goldfish,Gold,Freshwater
|
||||||
|
|
||||||
|
#Clownfish,Orange,Coral Reefs
|
||||||
|
Tuna,Silver,Saltwater
|
||||||
|
|
||||||
|
Shark,Grey,Saltwater
|
||||||
|
Salmon,Silver`,
|
||||||
|
sampleResult: `Fish Type,Goldfish,Tuna,Shark,Salmon
|
||||||
|
Color,Gold,Silver,Grey,Silver
|
||||||
|
Habitat,Freshwater,Saltwater,Saltwater,•`,
|
||||||
|
sampleOptions: {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: true,
|
||||||
|
customFillValue: '•',
|
||||||
|
quoteChar: '"'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
export default function TransposeCsv({
|
||||||
|
title,
|
||||||
|
longDescription
|
||||||
|
}: ToolComponentProps) {
|
||||||
|
const [input, setInput] = useState<string>('');
|
||||||
|
const [result, setResult] = useState<string>('');
|
||||||
|
|
||||||
|
const compute = (values: InitialValuesType, input: string) => {
|
||||||
|
setResult(transposeCSV(input, values));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||||
|
values,
|
||||||
|
updateField
|
||||||
|
}) => [
|
||||||
|
{
|
||||||
|
title: 'Csv input 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.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Fixing CSV Options',
|
||||||
|
component: (
|
||||||
|
<Box>
|
||||||
|
<SelectWithDesc
|
||||||
|
selected={values.customFill}
|
||||||
|
options={[
|
||||||
|
{ label: 'Fill With Empty Values', value: false },
|
||||||
|
{ label: 'Fill with Custom Values', value: true }
|
||||||
|
]}
|
||||||
|
onChange={(value) => updateField('customFill', value)}
|
||||||
|
description={
|
||||||
|
'Insert empty fields or custom values where the CSV data is missing (not empty).'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{values.customFill && (
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.customFillValue}
|
||||||
|
onOwnChange={(val) => updateField('customFillValue', val)}
|
||||||
|
description={
|
||||||
|
'Enter the character used to fill missing values in the CSV input file.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ToolContent
|
||||||
|
title={title}
|
||||||
|
input={input}
|
||||||
|
inputComponent={
|
||||||
|
<ToolTextInput title="Input CSV" value={input} onChange={setInput} />
|
||||||
|
}
|
||||||
|
resultComponent={<ToolTextResult title="Transposed 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/transpose-csv/meta.ts
Normal file
15
src/pages/tools/csv/transpose-csv/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineTool } from '@tools/defineTool';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const tool = defineTool('csv', {
|
||||||
|
name: 'Transpose CSV',
|
||||||
|
path: 'transpose-csv',
|
||||||
|
icon: 'carbon:transpose',
|
||||||
|
description:
|
||||||
|
'Just upload your CSV file in the form below, and this tool will automatically transpose your CSV. In the tool options, you can specify the character that starts the comment lines in the CSV to remove them. Additionally, if the CSV is incomplete (missing values), you can replace missing values with the empty character or a custom character.',
|
||||||
|
shortDescription: 'Quickly transpose a CSV file.',
|
||||||
|
keywords: ['transpose', 'csv'],
|
||||||
|
longDescription:
|
||||||
|
'This tool transposes Comma Separated Values (CSV). It treats the CSV as a matrix of data and flips all elements across the main diagonal. The output contains the same CSV data as the input, but now all the rows have become columns, and all the columns have become rows. After transposition, the CSV file will have opposite dimensions. For example, if the input file has 4 columns and 3 rows, the output file will have 3 columns and 4 rows. During conversion, the program also cleans the data from unnecessary lines and corrects incomplete data. Specifically, the tool automatically deletes all empty records and comments that begin with a specific character, which you can set in the option. Additionally, in cases where the CSV data is corrupted or lost, the utility completes the file with empty fields or custom fields that can be specified in the options. Csv-abulous!',
|
||||||
|
component: lazy(() => import('./index'))
|
||||||
|
});
|
27
src/pages/tools/csv/transpose-csv/service.ts
Normal file
27
src/pages/tools/csv/transpose-csv/service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { InitialValuesType } from './types';
|
||||||
|
import { transpose, normalizeAndFill } from '@utils/array';
|
||||||
|
import { splitCsv } from '@utils/csv';
|
||||||
|
|
||||||
|
export function transposeCSV(
|
||||||
|
input: string,
|
||||||
|
options: InitialValuesType
|
||||||
|
): string {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const rows = splitCsv(
|
||||||
|
input,
|
||||||
|
true,
|
||||||
|
options.commentCharacter,
|
||||||
|
true,
|
||||||
|
options.separator,
|
||||||
|
options.quoteChar
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizeAndFillRows = options.customFill
|
||||||
|
? normalizeAndFill(rows, options.customFillValue)
|
||||||
|
: normalizeAndFill(rows, '');
|
||||||
|
|
||||||
|
return transpose(normalizeAndFillRows)
|
||||||
|
.map((row) => row.join(options.separator))
|
||||||
|
.join('\n');
|
||||||
|
}
|
@@ -0,0 +1,89 @@
|
|||||||
|
import { expect, describe, it } from 'vitest';
|
||||||
|
import { transposeCSV } from './service';
|
||||||
|
import { InitialValuesType } from './types';
|
||||||
|
|
||||||
|
describe('transposeCsv', () => {
|
||||||
|
it('should transpose a simple CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = 'a,b,c\n1,2,3';
|
||||||
|
const expectedOutput = 'a,1\nb,2\nc,3';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = '';
|
||||||
|
const expectedOutput = '';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single row CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = 'a,b,c';
|
||||||
|
const expectedOutput = 'a\nb\nc';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a single column CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: false,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = 'a\nb\nc';
|
||||||
|
const expectedOutput = 'a,b,c';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle uneven rows in the CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: true,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = 'a,b\n1,2,3';
|
||||||
|
const expectedOutput = 'a,1\nb,2\nx,3';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip comment in the CSV', () => {
|
||||||
|
const options: InitialValuesType = {
|
||||||
|
separator: ',',
|
||||||
|
commentCharacter: '#',
|
||||||
|
customFill: true,
|
||||||
|
customFillValue: 'x',
|
||||||
|
quoteChar: '"'
|
||||||
|
};
|
||||||
|
const input = 'a,b\n1,2\n#c,3\nd,4';
|
||||||
|
const expectedOutput = 'a,1,d\nb,2,4';
|
||||||
|
const result = transposeCSV(input, options);
|
||||||
|
expect(result).toEqual(expectedOutput);
|
||||||
|
});
|
||||||
|
});
|
7
src/pages/tools/csv/transpose-csv/types.ts
Normal file
7
src/pages/tools/csv/transpose-csv/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type InitialValuesType = {
|
||||||
|
separator: string;
|
||||||
|
commentCharacter: string;
|
||||||
|
customFill: boolean;
|
||||||
|
customFillValue: string;
|
||||||
|
quoteChar: string;
|
||||||
|
};
|
@@ -37,7 +37,6 @@ export async function protectPdf(
|
|||||||
password: options.password
|
password: options.password
|
||||||
};
|
};
|
||||||
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
|
const protectedFileUrl: string = await protectWithGhostScript(dataObject);
|
||||||
console.log('protected', protectedFileUrl);
|
|
||||||
return await loadPDFData(
|
return await loadPDFData(
|
||||||
protectedFileUrl,
|
protectedFileUrl,
|
||||||
pdfFile.name.replace('.pdf', '-protected.pdf')
|
pdfFile.name.replace('.pdf', '-protected.pdf')
|
||||||
|
169
src/pages/tools/video/change-speed/index.tsx
Normal file
169
src/pages/tools/video/change-speed/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import ToolContent from '@components/ToolContent';
|
||||||
|
import { ToolComponentProps } from '@tools/defineTool';
|
||||||
|
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||||
|
import { InitialValuesType } from './types';
|
||||||
|
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||||
|
import ToolFileResult from '@components/result/ToolFileResult';
|
||||||
|
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||||
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||||
|
import { fetchFile } from '@ffmpeg/util';
|
||||||
|
|
||||||
|
const initialValues: InitialValuesType = {
|
||||||
|
newSpeed: 2
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ChangeSpeed({
|
||||||
|
title,
|
||||||
|
longDescription
|
||||||
|
}: ToolComponentProps) {
|
||||||
|
const [input, setInput] = useState<File | null>(null);
|
||||||
|
const [result, setResult] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// FFmpeg only supports a tempo between 0.5 and 2.0, so we chain filters
|
||||||
|
const computeAudioFilter = (speed: number): string => {
|
||||||
|
if (speed <= 2 && speed >= 0.5) {
|
||||||
|
return `atempo=${speed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Break into supported chunks
|
||||||
|
const filters: string[] = [];
|
||||||
|
let remainingSpeed = speed;
|
||||||
|
while (remainingSpeed > 2.0) {
|
||||||
|
filters.push('atempo=2.0');
|
||||||
|
remainingSpeed /= 2.0;
|
||||||
|
}
|
||||||
|
while (remainingSpeed < 0.5) {
|
||||||
|
filters.push('atempo=0.5');
|
||||||
|
remainingSpeed /= 0.5;
|
||||||
|
}
|
||||||
|
filters.push(`atempo=${remainingSpeed.toFixed(2)}`);
|
||||||
|
|
||||||
|
return filters.join(',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const compute = (optionsValues: InitialValuesType, input: File | null) => {
|
||||||
|
if (!input) return;
|
||||||
|
const { newSpeed } = optionsValues;
|
||||||
|
let ffmpeg: FFmpeg | null = null;
|
||||||
|
let ffmpegLoaded = false;
|
||||||
|
|
||||||
|
const processVideo = async (
|
||||||
|
file: File,
|
||||||
|
newSpeed: number
|
||||||
|
): Promise<void> => {
|
||||||
|
if (newSpeed === 0) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!ffmpeg) {
|
||||||
|
ffmpeg = new FFmpeg();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ffmpegLoaded) {
|
||||||
|
await ffmpeg.load({
|
||||||
|
wasmURL:
|
||||||
|
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||||
|
});
|
||||||
|
ffmpegLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file to FFmpeg FS
|
||||||
|
const fileName = file.name;
|
||||||
|
const outputName = 'output.mp4';
|
||||||
|
|
||||||
|
try {
|
||||||
|
ffmpeg.writeFile(fileName, await fetchFile(file));
|
||||||
|
|
||||||
|
const videoFilter = `setpts=${1 / newSpeed}*PTS`;
|
||||||
|
const audioFilter = computeAudioFilter(newSpeed);
|
||||||
|
|
||||||
|
// Run FFmpeg command
|
||||||
|
await ffmpeg.exec([
|
||||||
|
'-i',
|
||||||
|
fileName,
|
||||||
|
'-vf',
|
||||||
|
videoFilter,
|
||||||
|
'-filter:a',
|
||||||
|
audioFilter,
|
||||||
|
'-c:v',
|
||||||
|
'libx264',
|
||||||
|
'-preset',
|
||||||
|
'ultrafast',
|
||||||
|
'-c:a',
|
||||||
|
'aac',
|
||||||
|
outputName
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = await ffmpeg.readFile(outputName);
|
||||||
|
|
||||||
|
// Create new file from processed data
|
||||||
|
const blob = new Blob([data], { type: 'video/mp4' });
|
||||||
|
const newFile = new File(
|
||||||
|
[blob],
|
||||||
|
file.name.replace('.mp4', `-${newSpeed}x.mp4`),
|
||||||
|
{ type: 'video/mp4' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up to free memory
|
||||||
|
await ffmpeg.deleteFile(fileName);
|
||||||
|
await ffmpeg.deleteFile(outputName);
|
||||||
|
|
||||||
|
setResult(newFile);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to process video: ${err}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Here we set the output video
|
||||||
|
processVideo(input, newSpeed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGroups: GetGroupsType<InitialValuesType> | null = ({
|
||||||
|
values,
|
||||||
|
updateField
|
||||||
|
}) => [
|
||||||
|
{
|
||||||
|
title: 'New Video Speed',
|
||||||
|
component: (
|
||||||
|
<Box>
|
||||||
|
<TextFieldWithDesc
|
||||||
|
value={values.newSpeed.toString()}
|
||||||
|
onOwnChange={(val) => updateField('newSpeed', Number(val))}
|
||||||
|
description="Default multiplier: 2 means 2x faster"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ToolContent
|
||||||
|
title={title}
|
||||||
|
input={input}
|
||||||
|
inputComponent={
|
||||||
|
<ToolVideoInput
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
title={'Input Video'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
resultComponent={
|
||||||
|
loading ? (
|
||||||
|
<ToolFileResult title="Setting Speed" value={null} loading={true} />
|
||||||
|
) : (
|
||||||
|
<ToolFileResult title="Edited Video" value={result} extension="mp4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
initialValues={initialValues}
|
||||||
|
getGroups={getGroups}
|
||||||
|
setInput={setInput}
|
||||||
|
compute={compute}
|
||||||
|
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
13
src/pages/tools/video/change-speed/meta.ts
Normal file
13
src/pages/tools/video/change-speed/meta.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineTool } from '@tools/defineTool';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const tool = defineTool('video', {
|
||||||
|
name: 'Change speed',
|
||||||
|
path: 'change-speed',
|
||||||
|
icon: 'material-symbols-light:speed-outline',
|
||||||
|
description:
|
||||||
|
'This online utility lets you change the speed of a video. You can speed it up or slow it down.',
|
||||||
|
shortDescription: 'Quickly change video speed',
|
||||||
|
keywords: ['change', 'speed'],
|
||||||
|
component: lazy(() => import('./index'))
|
||||||
|
});
|
8
src/pages/tools/video/change-speed/service.ts
Normal file
8
src/pages/tools/video/change-speed/service.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { InitialValuesType } from './types';
|
||||||
|
|
||||||
|
export function main(
|
||||||
|
input: File | null,
|
||||||
|
options: InitialValuesType
|
||||||
|
): File | null {
|
||||||
|
return input;
|
||||||
|
}
|
3
src/pages/tools/video/change-speed/types.ts
Normal file
3
src/pages/tools/video/change-speed/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type InitialValuesType = {
|
||||||
|
newSpeed: 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;
|
||||||
|
};
|
113
src/pages/tools/video/flip/index.tsx
Normal file
113
src/pages/tools/video/flip/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import ToolFileResult from '@components/result/ToolFileResult';
|
||||||
|
import ToolContent from '@components/ToolContent';
|
||||||
|
import { ToolComponentProps } from '@tools/defineTool';
|
||||||
|
import { GetGroupsType } from '@components/options/ToolOptions';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import ToolVideoInput from '@components/input/ToolVideoInput';
|
||||||
|
import { flipVideo } from './service';
|
||||||
|
import { FlipOrientation, InitialValuesType } from './types';
|
||||||
|
import SimpleRadio from '@components/options/SimpleRadio';
|
||||||
|
|
||||||
|
export const initialValues: InitialValuesType = {
|
||||||
|
orientation: 'horizontal'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validationSchema = Yup.object({
|
||||||
|
orientation: Yup.string()
|
||||||
|
.oneOf(
|
||||||
|
['horizontal', 'vertical'],
|
||||||
|
'Orientation must be horizontal or vertical'
|
||||||
|
)
|
||||||
|
.required('Orientation is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
const orientationOptions: { value: FlipOrientation; label: string }[] = [
|
||||||
|
{ value: 'horizontal', label: 'Horizontal (Mirror)' },
|
||||||
|
{ value: 'vertical', label: 'Vertical (Upside Down)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FlipVideo({ title }: ToolComponentProps) {
|
||||||
|
const [input, setInput] = useState<File | null>(null);
|
||||||
|
const [result, setResult] = useState<File | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const compute = async (
|
||||||
|
optionsValues: InitialValuesType,
|
||||||
|
input: File | null
|
||||||
|
) => {
|
||||||
|
if (!input) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const flippedFile = await flipVideo(input, optionsValues.orientation);
|
||||||
|
setResult(flippedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error flipping video:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedCompute = useCallback(debounce(compute, 1000), []);
|
||||||
|
|
||||||
|
const getGroups: GetGroupsType<InitialValuesType> = ({
|
||||||
|
values,
|
||||||
|
updateField
|
||||||
|
}) => [
|
||||||
|
{
|
||||||
|
title: 'Orientation',
|
||||||
|
component: (
|
||||||
|
<Box>
|
||||||
|
{orientationOptions.map((orientationOption) => (
|
||||||
|
<SimpleRadio
|
||||||
|
key={orientationOption.value}
|
||||||
|
title={orientationOption.label}
|
||||||
|
checked={values.orientation === orientationOption.value}
|
||||||
|
onClick={() => {
|
||||||
|
updateField('orientation', orientationOption.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolContent
|
||||||
|
title={title}
|
||||||
|
input={input}
|
||||||
|
inputComponent={
|
||||||
|
<ToolVideoInput
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
title={'Input Video'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
resultComponent={
|
||||||
|
loading ? (
|
||||||
|
<ToolFileResult
|
||||||
|
title={'Flipping Video'}
|
||||||
|
value={null}
|
||||||
|
loading={true}
|
||||||
|
extension={''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ToolFileResult
|
||||||
|
title={'Flipped Video'}
|
||||||
|
value={result}
|
||||||
|
extension={'mp4'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
initialValues={initialValues}
|
||||||
|
getGroups={getGroups}
|
||||||
|
compute={debouncedCompute}
|
||||||
|
setInput={setInput}
|
||||||
|
validationSchema={validationSchema}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
15
src/pages/tools/video/flip/meta.ts
Normal file
15
src/pages/tools/video/flip/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineTool } from '@tools/defineTool';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
export const tool = defineTool('video', {
|
||||||
|
name: 'Flip Video',
|
||||||
|
path: 'flip',
|
||||||
|
icon: 'mdi:flip-horizontal',
|
||||||
|
description:
|
||||||
|
'This online utility allows you to flip videos horizontally or vertically. You can preview the flipped video before processing. Supports common video formats like MP4, WebM, and OGG.',
|
||||||
|
shortDescription: 'Flip videos horizontally or vertically',
|
||||||
|
keywords: ['flip', 'video', 'mirror', 'edit', 'horizontal', 'vertical'],
|
||||||
|
longDescription:
|
||||||
|
'Easily flip your videos horizontally (mirror) or vertically (upside down) with this simple online tool.',
|
||||||
|
component: lazy(() => import('./index'))
|
||||||
|
});
|
43
src/pages/tools/video/flip/service.ts
Normal file
43
src/pages/tools/video/flip/service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||||
|
import { fetchFile } from '@ffmpeg/util';
|
||||||
|
import { FlipOrientation } from './types';
|
||||||
|
|
||||||
|
const ffmpeg = new FFmpeg();
|
||||||
|
|
||||||
|
export async function flipVideo(
|
||||||
|
input: File,
|
||||||
|
orientation: FlipOrientation
|
||||||
|
): 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 flipMap: Record<FlipOrientation, string> = {
|
||||||
|
horizontal: 'hflip',
|
||||||
|
vertical: 'vflip'
|
||||||
|
};
|
||||||
|
const flipFilter = flipMap[orientation];
|
||||||
|
|
||||||
|
const args = ['-i', inputName];
|
||||||
|
if (flipFilter) {
|
||||||
|
args.push('-vf', flipFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
|
||||||
|
|
||||||
|
await ffmpeg.exec(args);
|
||||||
|
|
||||||
|
const flippedData = await ffmpeg.readFile(outputName);
|
||||||
|
return new File(
|
||||||
|
[new Blob([flippedData], { type: 'video/mp4' })],
|
||||||
|
`${input.name.replace(/\.[^/.]+$/, '')}_flipped.mp4`,
|
||||||
|
{ type: 'video/mp4' }
|
||||||
|
);
|
||||||
|
}
|
5
src/pages/tools/video/flip/types.ts
Normal file
5
src/pages/tools/video/flip/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type FlipOrientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export type InitialValuesType = {
|
||||||
|
orientation: FlipOrientation;
|
||||||
|
};
|
@@ -1,14 +1,22 @@
|
|||||||
|
import { tool as videoChangeSpeed } from './change-speed/meta';
|
||||||
|
import { tool as videoFlip } from './flip/meta';
|
||||||
import { rotate } from '../string/rotate/service';
|
import { rotate } from '../string/rotate/service';
|
||||||
import { gifTools } from './gif';
|
import { gifTools } from './gif';
|
||||||
import { tool as trimVideo } from './trim/meta';
|
import { tool as trimVideo } from './trim/meta';
|
||||||
import { tool as rotateVideo } from './rotate/meta';
|
import { tool as rotateVideo } from './rotate/meta';
|
||||||
import { tool as compressVideo } from './compress/meta';
|
import { tool as compressVideo } from './compress/meta';
|
||||||
import { tool as loopVideo } from './loop/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 = [
|
export const videoTools = [
|
||||||
...gifTools,
|
...gifTools,
|
||||||
trimVideo,
|
trimVideo,
|
||||||
rotateVideo,
|
rotateVideo,
|
||||||
compressVideo,
|
compressVideo,
|
||||||
loopVideo
|
loopVideo,
|
||||||
|
flipVideo,
|
||||||
|
cropVideo,
|
||||||
|
changeSpeed
|
||||||
];
|
];
|
||||||
|
33
src/utils/array.ts
Normal file
33
src/utils/array.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Transpose a 2D array (matrix).
|
||||||
|
* @param {any[][]} matrix - The 2D array to transpose.
|
||||||
|
* @returns {any[][]} - The transposed 2D array.
|
||||||
|
**/
|
||||||
|
|
||||||
|
export function transpose<T>(matrix: T[][]): any[][] {
|
||||||
|
return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
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) {
|
||||||
|
filledRow.push(fillValue);
|
||||||
|
}
|
||||||
|
return filledRow;
|
||||||
|
});
|
||||||
|
}
|
Reference in New Issue
Block a user