Merge branch 'main' into json-escaper-#49

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-30 12:41:45 +00:00
committed by GitHub
22 changed files with 1135 additions and 138 deletions

268
.idea/workspace.xml generated
View File

@@ -4,10 +4,11 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: show new tools in landing">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="refactor: time between dates">
<change afterPath="$PROJECT_DIR$/src/components/BackButton.tsx" 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/result/ToolTextResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/json/escape-json/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/json/escape-json/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -24,7 +25,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="main" />
<entry key="$PROJECT_DIR$" value="fork/lfsjesus/feature/rotate-video" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -43,59 +44,73 @@
&quot;state&quot;: &quot;OPEN&quot;
}
}</component>
<component name="GitHubPullRequestState">{
&quot;prStates&quot;: [
<component name="GitHubPullRequestState"><![CDATA[{
"prStates": [
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts51PkS9&quot;,
&quot;number&quot;: 22
"id": {
"id": "PR_kwDOMJIfts51PkS9",
"number": 22
},
&quot;lastSeen&quot;: 1741207144695
"lastSeen": 1741207144695
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NiNYl&quot;,
&quot;number&quot;: 32
"id": {
"id": "PR_kwDOMJIfts6NiNYl",
"number": 32
},
&quot;lastSeen&quot;: 1741209723869
"lastSeen": 1741209723869
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Nheyd&quot;,
&quot;number&quot;: 31
"id": {
"id": "PR_kwDOMJIfts6Nheyd",
"number": 31
},
&quot;lastSeen&quot;: 1741213371410
"lastSeen": 1741213371410
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6NmRBs&quot;,
&quot;number&quot;: 33
"id": {
"id": "PR_kwDOMJIfts6NmRBs",
"number": 33
},
&quot;lastSeen&quot;: 1741282429036
"lastSeen": 1741282429036
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts5zyFTs&quot;,
&quot;number&quot;: 15
"id": {
"id": "PR_kwDOMJIfts5zyFTs",
"number": 15
},
&quot;lastSeen&quot;: 1741535540953
"lastSeen": 1741535540953
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QQB3c&quot;,
&quot;number&quot;: 59
"id": {
"id": "PR_kwDOMJIfts6QQB3c",
"number": 59
},
&quot;lastSeen&quot;: 1743018960900
"lastSeen": 1743018960900
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QMPEg&quot;,
&quot;number&quot;: 58
"id": {
"id": "PR_kwDOMJIfts6QMPEg",
"number": 58
},
&quot;lastSeen&quot;: 1743019452983
"lastSeen": 1743019452983
},
{
"id": {
"id": "PR_kwDOMJIfts6QZvRI",
"number": 61
},
"lastSeen": 1743103196866
},
{
"id": {
"id": "PR_kwDOMJIfts6QqPrQ",
"number": 73
},
"lastSeen": 1743265865001
}
]
}</component>
}]]></component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;,
@@ -124,55 +139,56 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ASKED_ADD_EXTERNAL_FILES": "true",
"ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
"Docker.Dockerfile build.executor": "Run",
"Docker.Dockerfile.executor": "Run",
"Playwright.Create transparent PNG.should make png color transparent.executor": "Run",
"Playwright.JoinText Component.executor": "Run",
"Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run",
"RunOnceActivity.OpenProjectViewOnStart": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"Vitest.compute function (1).executor": "Run",
"Vitest.compute function.executor": "Run",
"Vitest.mergeText.executor": "Run",
"Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run",
"Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run",
"Vitest.parsePageRanges.executor": "Run",
"Vitest.removeDuplicateLines function.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.executor": "Run",
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
"git-widget-placeholder": "fork/AneekG8/json-escaper-#49",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/components/input",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"npm.build.executor": "Run",
"npm.dev.executor": "Run",
"npm.lint.executor": "Run",
"npm.prebuild.executor": "Run",
"npm.script:create:tool.executor": "Run",
"npm.test.executor": "Run",
"npm.test:e2e.executor": "Run",
"npm.test:e2e:run.executor": "Run",
"prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier",
"project.structure.last.edited": "Problems",
"project.structure.proportion": "0.0",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "refactai_advanced_settings",
"ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ASKED_ADD_EXTERNAL_FILES&quot;: &quot;true&quot;,
&quot;ASKED_SHARE_PROJECT_CONFIGURATION_FILES&quot;: &quot;true&quot;,
&quot;Docker.Dockerfile build.executor&quot;: &quot;Run&quot;,
&quot;Docker.Dockerfile.executor&quot;: &quot;Run&quot;,
&quot;Playwright.Create transparent PNG.should make png color transparent.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.executor&quot;: &quot;Run&quot;,
&quot;Playwright.JoinText Component.should merge text pieces with specified join character.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;Vitest.compute function (1).executor&quot;: &quot;Run&quot;,
&quot;Vitest.compute function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor&quot;: &quot;Run&quot;,
&quot;Vitest.parsePageRanges.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.executor&quot;: &quot;Run&quot;,
&quot;Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor&quot;: &quot;Run&quot;,
&quot;Vitest.replaceText function.executor&quot;: &quot;Run&quot;,
&quot;Vitest.timeBetweenDates.executor&quot;: &quot;Run&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/Ibrahima/IdeaProjects/omni-tools/src/components/input&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;npm.dev.executor&quot;: &quot;Run&quot;,
&quot;npm.lint.executor&quot;: &quot;Run&quot;,
&quot;npm.prebuild.executor&quot;: &quot;Run&quot;,
&quot;npm.script:create:tool.executor&quot;: &quot;Run&quot;,
&quot;npm.test.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e.executor&quot;: &quot;Run&quot;,
&quot;npm.test:e2e:run.executor&quot;: &quot;Run&quot;,
&quot;prettierjs.PrettierConfiguration.Package&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier&quot;,
&quot;project.structure.last.edited&quot;: &quot;Problems&quot;,
&quot;project.structure.proportion&quot;: &quot;0.0&quot;,
&quot;project.structure.side.proportion&quot;: &quot;0.2&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;refactai_advanced_settings&quot;,
&quot;ts.external.directory.path&quot;: &quot;C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}]]></component>
}</component>
<component name="ReactDesignerToolWindowState">
<option name="myId2Visible">
<map>
@@ -198,17 +214,17 @@
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" />
</key>
</component>
<component name="RunManager" selected="npm.dev">
<configuration name="Create transparent PNG.should make png color transparent" type="JavaScriptTestRunnerPlaywright" temporary="true" nameIsGenerated="true">
<component name="RunManager" selected="Vitest.calculateTimeBetweenDates">
<configuration name="calculateTimeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<playwright-package value="$PROJECT_DIR$/node_modules/@playwright/test" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs />
<scope-kind value="TEST" />
<test-file value="$PROJECT_DIR$/src/pages/tools/image/png/create-transparent/create-transparent.e2e.spec.ts" />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts" />
<test-names>
<test-name value="Create transparent PNG" />
<test-name value="should make png color transparent" />
<test-name value="calculateTimeBetweenDates" />
</test-names>
<method v="2" />
</configuration>
@@ -239,6 +255,19 @@
</test-names>
<method v="2" />
</configuration>
<configuration name="timeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts" />
<test-names>
<test-name value="timeBetweenDates" />
</test-names>
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile">
<settings />
@@ -255,30 +284,20 @@
<envs />
<method v="2" />
</configuration>
<configuration name="test:e2e" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="test:e2e" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
<list>
<item itemvalue="npm.test:e2e" />
<item itemvalue="npm.dev" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" />
<item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
</list>
<recent_temporary>
<list>
<item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="npm.dev" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" />
<item itemvalue="npm.test:e2e" />
</list>
</recent_temporary>
</component>
@@ -364,22 +383,7 @@
<workItem from="1741971589699" duration="371000" />
<workItem from="1743018497879" duration="3895000" />
<workItem from="1743047367993" duration="986000" />
</task>
<task id="LOCAL-00122" summary="fix: bg">
<option name="closed" value="true" />
<created>1740504100676</created>
<option name="number" value="00122" />
<option name="presentableId" value="LOCAL-00122" />
<option name="project" value="LOCAL" />
<updated>1740504100676</updated>
</task>
<task id="LOCAL-00123" summary="fix: bg">
<option name="closed" value="true" />
<created>1740505390205</created>
<option name="number" value="00123" />
<option name="presentableId" value="LOCAL-00123" />
<option name="project" value="LOCAL" />
<updated>1740505390205</updated>
<workItem from="1743103182313" duration="4264000" />
</task>
<task id="LOCAL-00124" summary="docs: readme">
<option name="closed" value="true" />
@@ -757,7 +761,23 @@
<option name="project" value="LOCAL" />
<updated>1743051792459</updated>
</task>
<option name="localTasksCounter" value="171" />
<task id="LOCAL-00171" summary="chore: zoom on hover">
<option name="closed" value="true" />
<created>1743052111988</created>
<option name="number" value="00171" />
<option name="presentableId" value="LOCAL-00171" />
<option name="project" value="LOCAL" />
<updated>1743052111988</updated>
</task>
<task id="LOCAL-00172" summary="refactor: time between dates">
<option name="closed" value="true" />
<created>1743106796406</created>
<option name="number" value="00172" />
<option name="presentableId" value="LOCAL-00172" />
<option name="project" value="LOCAL" />
<updated>1743106796406</updated>
</task>
<option name="localTasksCounter" value="173" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -804,8 +824,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="fix: replace text service" />
<MESSAGE value="chore: smooth scroll for use this tool and examles" />
<MESSAGE value="feat: minify json" />
<MESSAGE value="feat: stringify json" />
<MESSAGE value="feat: arithmetic sequence" />
@@ -829,7 +847,9 @@
<MESSAGE value="chore: result file name" />
<MESSAGE value="chore: text result extensions" />
<MESSAGE value="chore: show new tools in landing" />
<option name="LAST_COMMIT_MESSAGE" value="chore: show new tools in landing" />
<MESSAGE value="chore: zoom on hover" />
<MESSAGE value="refactor: time between dates" />
<option name="LAST_COMMIT_MESSAGE" value="refactor: time between dates" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

View File

@@ -2,7 +2,7 @@
<img src="src/assets/logo.png" width="300" />
<br /><br />
<a href="https://github.com/iib0011/omni-tools/releases">
<img src="https://img.shields.io/badge/version-0.1.0-blue?style=for-the-badge" />
<img src="https://img.shields.io/badge/version-0.2.0-blue?style=for-the-badge" />
</a>
<a href="https://hub.docker.com/r/iib0011/omni-tools">
<img src="https://img.shields.io/docker/pulls/iib0011/omni-tools?style=for-the-badge&logo=docker" />
@@ -13,18 +13,16 @@
<a href="https://github.com/iib0011/omni-tools/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/iib0011/omni-tools?style=for-the-badge" />
</a>
<!--
<a href="https://discord.gg/SDbbn3hT4b">
<img src="https://img.shields.io/discord/1342971141823664179?label=Discord&style=for-the-badge" />
</a>
-->
<br /><br />
</p>
Welcome to OmniTools, a self-hosted web app offering a variety of online tools to simplify everyday tasks.
Whether you are coding, manipulating images or crunching numbers, OmniTools has you covered. Please don't forget to
star the repo to support us.
Here is the [demo](https://omnitools.netlify.app/) website.
Here is the [demo](https://omnitools.app) website.
![img.png](img.png)
@@ -86,9 +84,7 @@ docker run -d --name omni-tools --restart unless-stopped -p 8080:80 iib0011/omni
### Docker Compose
```
version: '3.3'
```yaml
services:
omni-tools:
image: iib0011/omni-tools:latest

BIN
img.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,20 @@
import { IconButton } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useNavigate, useNavigationType } from 'react-router-dom';
const BackButton = () => {
const navigate = useNavigate();
const navigationType = useNavigationType(); // Check if there is a history state
const disabled = navigationType === 'POP';
const handleBack = () => {
navigate(-1);
};
return (
<IconButton onClick={handleBack} disabled={disabled}>
<ArrowBackIcon color={disabled ? 'action' : 'primary'} />
</IconButton>
);
};
export default BackButton;

View File

@@ -8,6 +8,10 @@ import { capitalizeFirstLetter } from '@utils/string';
import { Icon } from '@iconify/react';
import { categoriesColors } from 'config/uiConfig';
import React, { useEffect } from 'react';
import IconButton from '@mui/material/IconButton';
import { ArrowBack } from '@mui/icons-material';
import BackButton from '@components/BackButton';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
export default function Home() {
const navigate = useNavigate();
@@ -35,10 +39,15 @@ export default function Home() {
</Box>
<Divider sx={{ borderColor: theme.palette.primary.main }} />
<Box ref={mainContentRef} mt={3} ml={{ xs: 1, md: 2, lg: 3 }} padding={3}>
<Stack direction={'row'} alignItems={'center'} spacing={1}>
<IconButton onClick={() => navigate('/')}>
<ArrowBackIcon color={'primary'} />
</IconButton>
<Typography
fontSize={22}
color={theme.palette.primary.main}
>{`All ${capitalizeFirstLetter(categoryName)} Tools`}</Typography>
</Stack>
<Grid container spacing={2} mt={2}>
{getToolsByCategory()
.find(({ type }) => type === categoryName)

View File

@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { csvRowsToColumns } from './service';
describe('csvRowsToColumns', () => {
it('should transpose rows to columns', () => {
const input = 'a,b,c\n1,2,3\nx,y,z';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('a,1,x\nb,2,y\nc,3,z');
});
it('should fill empty values with custom filler', () => {
const input = 'a,b\n1\nx,y,z';
const result = csvRowsToColumns(input, false, 'FILL', '#');
expect(result).toBe('a,1,x\nb,FILL,y\nFILL,FILL,z');
});
it('should fill empty values with empty strings when emptyValuesFilling is true', () => {
const input = 'a,b\n1\nx,y,z';
const result = csvRowsToColumns(input, true, 'FILL', '#');
expect(result).toBe('a,1,x\nb,,y\n,,z');
});
it('should ignore rows starting with the comment character', () => {
const input = '#comment\n1,2,3\nx,y,z';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('1,x\n2,y\n3,z');
});
it('should handle an empty input', () => {
const input = '';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('');
});
it('should handle input with only comments', () => {
const input = '#comment\n#another comment';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('');
});
it('should handle single row input', () => {
const input = 'a,b,c';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('a\nb\nc');
});
it('should handle single column input', () => {
const input = 'a\nb\nc';
const result = csvRowsToColumns(input, false, '', '#');
expect(result).toBe('a,b,c');
});
it('should handle this case onlinetools #1', () => {
const input =
'Variety,Origin\nArabica,Ethiopia\nRobusta,Africa\nLiberica,Philippines\nMocha,\n//green tea';
const result = csvRowsToColumns(input, false, '1x', '//');
expect(result).toBe(
'Variety,Arabica,Robusta,Liberica,Mocha\nOrigin,Ethiopia,Africa,Philippines,1x'
);
});
});

View File

@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import ToolContent from '@components/ToolContent';
import { GetGroupsType } from '@components/options/ToolOptions';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import SelectWithDesc from '@components/options/SelectWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { csvRowsToColumns } from './service';
const initialValues = {
emptyValuesFilling: false,
customFiller: 'x',
commentCharacter: '//'
};
type InitialValuesType = typeof initialValues;
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Convert CSV Rows to Columns',
description:
'In this example, we transform the input CSV file with a single horizontal row of six values "a,b,c,d,e,f" into a vertical column. The program takes this row, rotates it 90 degrees, and outputs it as a column with each CSV value on a new line. This operation can also be viewed as converting a 6-dimensional row vector into a 6-dimensional column vector.',
sampleText: `a,b,c,d,e,f`,
sampleResult: `a
b
c
d
e
f`,
sampleOptions: {
emptyValuesFilling: true,
customFiller: '1',
commentCharacter: '#'
}
},
{
title: 'Rows to Columns Transformation',
description:
'In this example, we load a CSV file containing coffee varieties and their origins. The file is quite messy, with numerous empty lines and comments, and it is hard to work with. To clean up the file, we specify the comment pattern // in the options, and the program automatically removes the comment lines from the input. Also, the empty lines are automatically removed. Once the file is cleaned up, we transform the five clean rows into five columns, each having a height of two fields.',
sampleText: `Variety,Origin
Arabica,Ethiopia
Robusta,Africa
Liberica,Philippines
Mocha,Yemen
//green tea`,
sampleResult: `Variety,Arabica,Robusta,Liberica,Mocha
Origin,Ethiopia,Africa,Philippines,Yemen`,
sampleOptions: {
emptyValuesFilling: true,
customFiller: '1',
commentCharacter: '//'
}
},
{
title: 'Fill Missing Data',
description:
'In this example, we swap rows and columns in CSV data about team sports, the equipment used, and the number of players. The input has 5 rows and 3 columns and once rows and columns have been swapped, the output has 3 rows and 5 columns. Also notice that in the last data record, for the "Baseball" game, the number of players is missing. To create a fully-filled CSV, we use a custom message "NA", specified in the options, and fill the missing CSV field with this value.',
sampleText: `Sport,Equipment,Players
Basketball,Ball,5
Football,Ball,11
Soccer,Ball,11
Baseball,Bat & Ball`,
sampleResult: `Sport,Basketball,Football,Soccer,Baseball
Equipment,Ball,Ball,Ball,Bat & Ball
Players,5,11,11,NA`,
sampleOptions: {
emptyValuesFilling: false,
customFiller: 'NA',
commentCharacter: '#'
}
}
];
export default function CsvRowsToColumns({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (optionsValues: typeof initialValues, input: string) => {
setResult(
csvRowsToColumns(
input,
optionsValues.emptyValuesFilling,
optionsValues.customFiller,
optionsValues.commentCharacter
)
);
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Fix incomplete data',
component: (
<Box>
<SelectWithDesc
selected={values.emptyValuesFilling}
options={[
{ label: 'Fill With Empty Values', value: true },
{ label: 'Fill With Customs Values', value: false }
]}
onChange={(value) => updateField('emptyValuesFilling', 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.emptyValuesFilling && (
<TextFieldWithDesc
value={values.customFiller}
onOwnChange={(val) => updateField('customFiller', val)}
description={
'Use this custom value to fill in missing fields. (Works only with "Custom Values" mode above.)'
}
/>
)}
</Box>
)
},
{
title: 'Lines with comments',
component: (
<Box>
<TextFieldWithDesc
value={values.commentCharacter}
onOwnChange={(val) => updateField('commentCharacter', val)}
description={
'Enter the symbol indicating the start of a comment line. (These lines are removed during conversion.)'
}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
resultComponent={<ToolTextResult value={result} />}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
exampleCards={exampleCards}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('csv', {
name: 'Convert CSV Rows to Columns',
path: 'csv-rows-to-columns',
icon: 'carbon:transpose',
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.',
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!.',
shortDescription: 'Convert CSV data to JSON format',
keywords: ['csv', 'rows', 'columns', 'transpose'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,40 @@
function compute(rows: string[][], columnCount: number): string[][] {
const result: string[][] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
for (let j = 0; j < columnCount; j++) {
if (!result[j]) {
result[j] = [];
}
result[j][i] = row[j];
}
}
return result;
}
export function csvRowsToColumns(
input: string,
emptyValuesFilling: boolean,
customFiller: string,
commentCharacter: string
): string {
if (!input) {
return '';
}
const rows = input
.split('\n')
.map((row) => row.split(','))
.filter(
(row) => row.length > 0 && !row[0].trim().startsWith(commentCharacter)
);
const columnCount = Math.max(...rows.map((row) => row.length));
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < columnCount; j++) {
if (!rows[i][j]) {
rows[i][j] = emptyValuesFilling ? '' : customFiller;
}
}
}
const result = compute(rows, columnCount);
return result.join('\n');
}

View File

@@ -1,4 +1,5 @@
import { tool as csvToJson } from './csv-to-json/meta';
import { tool as csvToXml } from './csv-to-xml/meta';
import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
export const csvTools = [csvToJson, csvToXml];
export const csvTools = [csvToJson, csvToXml, csvToRowsColumns];

View File

@@ -1,3 +1,4 @@
import { tool as timeBetweenDates } from './time-between-dates/meta';
import { tool as daysDoHours } from './convert-days-to-hours/meta';
import { tool as hoursToDays } from './convert-hours-to-days/meta';
import { tool as convertSecondsToTime } from './convert-seconds-to-time/meta';
@@ -9,5 +10,6 @@ export const timeTools = [
hoursToDays,
convertSecondsToTime,
convertTimetoSeconds,
truncateClockTime
truncateClockTime,
timeBetweenDates
];

View File

@@ -0,0 +1,250 @@
import { Box, Paper, Typography } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import SelectWithDesc from '@components/options/SelectWithDesc';
import {
calculateTimeBetweenDates,
formatTimeWithLargestUnit,
getTimeWithTimezone,
unitHierarchy
} from './service';
import * as Yup from 'yup';
import { CardExampleType } from '@components/examples/ToolExamples';
type TimeUnit =
| 'milliseconds'
| 'seconds'
| 'minutes'
| 'hours'
| 'days'
| 'months'
| 'years';
type InitialValuesType = {
startDate: string;
startTime: string;
endDate: string;
endTime: string;
startTimezone: string;
endTimezone: string;
};
const initialValues: InitialValuesType = {
startDate: new Date().toISOString().split('T')[0],
startTime: '00:00',
endDate: new Date().toISOString().split('T')[0],
endTime: '12:00',
startTimezone: 'local',
endTimezone: 'local'
};
const validationSchema = Yup.object({
startDate: Yup.string().required('Start date is required'),
startTime: Yup.string().required('Start time is required'),
endDate: Yup.string().required('End date is required'),
endTime: Yup.string().required('End time is required'),
startTimezone: Yup.string(),
endTimezone: Yup.string()
});
const timezoneOptions = [
{ value: 'local', label: 'Local Time' },
...Intl.supportedValuesOf('timeZone')
.map((tz) => {
const formatter = new Intl.DateTimeFormat('en', {
timeZone: tz,
timeZoneName: 'shortOffset'
});
const offset =
formatter
.formatToParts(new Date())
.find((part) => part.type === 'timeZoneName')?.value || '';
return {
value: offset.replace('UTC', 'GMT'),
label: `${offset.replace('UTC', 'GMT')} (${tz})`
};
})
.sort((a, b) =>
a.value.localeCompare(b.value, undefined, { numeric: true })
)
];
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'One Year Difference',
description: 'Calculate the time between dates that are one year apart',
sampleOptions: {
startDate: '2023-01-01',
startTime: '12:00',
endDate: '2024-01-01',
endTime: '12:00',
startTimezone: 'local',
endTimezone: 'local'
},
sampleResult: '1 year'
},
{
title: 'Different Timezones',
description: 'Calculate the time difference between New York and London',
sampleOptions: {
startDate: '2023-01-01',
startTime: '12:00',
endDate: '2023-01-01',
endTime: '12:00',
startTimezone: 'GMT-5',
endTimezone: 'GMT'
},
sampleResult: '5 hours'
},
{
title: 'Detailed Time Breakdown',
description: 'Show a detailed breakdown of a time difference',
sampleOptions: {
startDate: '2023-01-01',
startTime: '09:30',
endDate: '2023-01-03',
endTime: '14:45',
startTimezone: 'local',
endTimezone: 'local'
},
sampleResult: '2 days, 5 hours, 15 minutes'
}
];
export default function TimeBetweenDates() {
const [result, setResult] = useState<string>('');
return (
<ToolContent
title="Time Between Dates"
inputComponent={null}
resultComponent={
result ? (
<Paper
elevation={3}
sx={{
p: 3,
borderLeft: '5px solid',
borderColor: 'primary.main',
bgcolor: 'background.paper',
maxWidth: '100%',
mx: 'auto'
}}
>
<Typography
variant="h4"
align="center"
sx={{ fontWeight: 'bold', color: 'primary.main' }}
>
{result}
</Typography>
</Paper>
) : null
}
initialValues={initialValues}
validationSchema={validationSchema}
exampleCards={exampleCards}
toolInfo={{
title: 'Time Between Dates Calculator',
description:
'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).'
}}
getGroups={({ values, updateField }) => [
{
title: 'Start Date & Time',
component: (
<Box>
<TextFieldWithDesc
description="Start Date"
value={values.startDate}
onOwnChange={(val) => updateField('startDate', val)}
type="date"
/>
<TextFieldWithDesc
description="Start Time"
value={values.startTime}
onOwnChange={(val) => updateField('startTime', val)}
type="time"
/>
<SelectWithDesc
description="Start Timezone"
selected={values.startTimezone}
onChange={(val: string) => updateField('startTimezone', val)}
options={timezoneOptions}
/>
</Box>
)
},
{
title: 'End Date & Time',
component: (
<Box>
<TextFieldWithDesc
description="End Date"
value={values.endDate}
onOwnChange={(val) => updateField('endDate', val)}
type="date"
/>
<TextFieldWithDesc
description="End Time"
value={values.endTime}
onOwnChange={(val) => updateField('endTime', val)}
type="time"
/>
<SelectWithDesc
description="End Timezone"
selected={values.endTimezone}
onChange={(val: string) => updateField('endTimezone', val)}
options={timezoneOptions}
/>
</Box>
)
}
]}
compute={(values) => {
try {
const startDateTime = getTimeWithTimezone(
values.startDate,
values.startTime,
values.startTimezone
);
const endDateTime = getTimeWithTimezone(
values.endDate,
values.endTime,
values.endTimezone
);
// Calculate time difference
const difference = calculateTimeBetweenDates(
startDateTime,
endDateTime
);
// Auto-determine the best unit to display based on the time difference
const bestUnit: TimeUnit =
unitHierarchy.find((unit) => difference[unit] > 0) ||
'milliseconds';
const formattedDifference = formatTimeWithLargestUnit(
difference,
bestUnit
);
setResult(formattedDifference);
} catch (error) {
setResult(
`Error: ${
error instanceof Error
? error.message
: 'Failed to calculate time difference'
}`
);
}
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('time', {
name: 'Time Between Dates',
path: 'time-between-dates',
icon: 'tabler:clock-minus',
description:
'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).',
shortDescription:
'Calculate the precise time duration between two dates with timezone support.',
keywords: [
'time',
'dates',
'difference',
'duration',
'calculator',
'timezones',
'interval'
],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,129 @@
export const unitHierarchy = [
'years',
'months',
'days',
'hours',
'minutes',
'seconds',
'milliseconds'
] as const;
export type TimeUnit = (typeof unitHierarchy)[number];
export type TimeDifference = Record<TimeUnit, number>;
export const calculateTimeBetweenDates = (
startDate: Date,
endDate: Date
): TimeDifference => {
if (endDate < startDate) {
const temp = startDate;
startDate = endDate;
endDate = temp;
}
const milliseconds = endDate.getTime() - startDate.getTime();
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
// Approximate months and years
const startYear = startDate.getFullYear();
const startMonth = startDate.getMonth();
const endYear = endDate.getFullYear();
const endMonth = endDate.getMonth();
const months = (endYear - startYear) * 12 + (endMonth - startMonth);
const years = Math.floor(months / 12);
return {
milliseconds,
seconds,
minutes,
hours,
days,
months,
years
};
};
export const formatTimeDifference = (
difference: TimeDifference,
includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1)
): string => {
const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [
{ key: 'years', value: difference.years },
{ key: 'months', value: difference.months, divisor: 12 },
{ key: 'days', value: difference.days, divisor: 30 },
{ key: 'hours', value: difference.hours, divisor: 24 },
{ key: 'minutes', value: difference.minutes, divisor: 60 },
{ key: 'seconds', value: difference.seconds, divisor: 60 }
];
const parts = timeUnits
.filter(({ key }) => includeUnits.includes(key))
.map(({ key, value, divisor }) => {
const remaining = divisor ? value % divisor : value;
return remaining > 0 ? `${remaining} ${key}` : '';
})
.filter(Boolean);
if (parts.length === 0) {
if (includeUnits.includes('milliseconds')) {
return `${difference.milliseconds} millisecond${
difference.milliseconds === 1 ? '' : 's'
}`;
}
return '0 seconds';
}
return parts.join(', ');
};
export const getTimeWithTimezone = (
dateString: string,
timeString: string,
timezone: string
): Date => {
// Combine date and time
const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing
const utcDate = new Date(dateTimeString);
if (isNaN(utcDate.getTime())) {
throw new Error('Invalid date or time format');
}
// If timezone is "local", return the local date
if (timezone === 'local') {
return utcDate;
}
// Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4")
const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/);
if (!match) {
throw new Error('Invalid timezone format');
}
const offsetHours = match[1] ? parseInt(match[1], 10) : 0;
const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0;
const totalOffsetMinutes =
offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes);
// Adjust the UTC date by the timezone offset
return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000);
};
// Helper function to format time based on largest unit
export const formatTimeWithLargestUnit = (
difference: TimeDifference,
largestUnit: TimeUnit
): string => {
const largestUnitIndex = unitHierarchy.indexOf(largestUnit);
const unitsToInclude = unitHierarchy.slice(largestUnitIndex);
// Preserve only whole values, do not apply fractional conversions
const adjustedDifference: TimeDifference = { ...difference };
return formatTimeDifference(adjustedDifference, unitsToInclude);
};

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
calculateTimeBetweenDates,
formatTimeDifference,
formatTimeWithLargestUnit,
getTimeWithTimezone
} from './service';
// Utility function to create a date
const createDate = (
year: number,
month: number,
day: number,
hours = 0,
minutes = 0,
seconds = 0
) => new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));
describe('calculateTimeBetweenDates', () => {
it('should calculate the correct time difference', () => {
const startDate = createDate(2023, 1, 1);
const endDate = createDate(2024, 1, 1);
const result = calculateTimeBetweenDates(startDate, endDate);
expect(result.years).toBe(1);
expect(result.months).toBe(12);
expect(result.days).toBeGreaterThanOrEqual(365);
});
it('should swap dates if startDate is after endDate', () => {
const startDate = createDate(2024, 1, 1);
const endDate = createDate(2023, 1, 1);
const result = calculateTimeBetweenDates(startDate, endDate);
expect(result.years).toBe(1);
});
});
describe('formatTimeDifference', () => {
it('should format time difference correctly', () => {
const difference = {
years: 1,
months: 2,
days: 10,
hours: 5,
minutes: 30,
seconds: 0,
milliseconds: 0
};
expect(formatTimeDifference(difference)).toBe(
'1 years, 2 months, 10 days, 5 hours, 30 minutes'
);
});
it('should return 0 seconds if all values are zero', () => {
expect(
formatTimeDifference({
years: 0,
months: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0
})
).toBe('0 seconds');
});
});
describe('getTimeWithTimezone', () => {
it('should convert UTC date to specified timezone', () => {
const date = getTimeWithTimezone('2025-03-27', '12:00:00', 'GMT+2');
expect(date.getUTCHours()).toBe(10); // 12:00 GMT+2 is 10:00 UTC
});
it('should throw error for invalid timezone', () => {
expect(() =>
getTimeWithTimezone('2025-03-27', '12:00:00', 'INVALID')
).toThrow('Invalid timezone format');
});
});
describe('formatTimeWithLargestUnit', () => {
it('should format time with the largest unit', () => {
const difference = {
years: 0,
months: 1,
days: 15,
hours: 12,
minutes: 0,
seconds: 0,
milliseconds: 0
};
expect(formatTimeWithLargestUnit(difference, 'days')).toContain(
'15 days, 12 hours'
);
});
});

View File

@@ -1,4 +1,6 @@
import { rotate } from '../string/rotate/service';
import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta';
import { tool as rotateVideo } from './rotate/meta';
export const videoTools = [...gifTools, trimVideo];
export const videoTools = [...gifTools, trimVideo, rotateVideo];

View File

@@ -0,0 +1,111 @@
import { Box } from '@mui/material';
import React, { 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 { rotateVideo } from './service';
import { RotationAngle } from '../../pdf/rotate-pdf/types';
import SimpleRadio from '@components/options/SimpleRadio';
export const initialValues = {
rotation: 90
};
export const validationSchema = Yup.object({
rotation: Yup.number()
.oneOf([0, 90, 180, 270], 'Rotation must be 0, 90, 180, or 270 degrees')
.required('Rotation is required')
});
const angleOptions: { value: RotationAngle; label: string }[] = [
{ value: 90, label: '90° Clockwise' },
{ value: 180, label: '180° (Upside down)' },
{ value: 270, label: '270° (90° Counter-clockwise)' }
];
export default function RotateVideo({ 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: typeof initialValues,
input: File | null
) => {
if (!input) return;
setLoading(true);
try {
const rotatedFile = await rotateVideo(input, optionsValues.rotation);
setResult(rotatedFile);
} catch (error) {
console.error('Error rotating video:', error);
} finally {
setLoading(false);
}
};
const debouncedCompute = useCallback(debounce(compute, 1000), []);
const getGroups: GetGroupsType<typeof initialValues> = ({
values,
updateField
}) => [
{
title: 'Rotation',
component: (
<Box>
{angleOptions.map((angleOption) => (
<SimpleRadio
key={angleOption.value}
title={angleOption.label}
checked={values.rotation === angleOption.value}
onClick={() => {
updateField('rotation', angleOption.value);
}}
/>
))}
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput
value={input}
onChange={setInput}
accept={['video/mp4', 'video/webm', 'video/ogg']}
title={'Input Video'}
/>
}
resultComponent={
loading ? (
<ToolFileResult
title={'Rotating Video'}
value={null}
loading={true}
extension={''}
/>
) : (
<ToolFileResult
title={'Rotated Video'}
value={result}
extension={'mp4'}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
compute={debouncedCompute}
setInput={setInput}
validationSchema={validationSchema}
/>
);
}

View File

@@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Rotate Video',
path: 'rotate',
icon: 'mdi:rotate-right',
description:
'This online utility lets you rotate videos by 90, 180, or 270 degrees. You can preview the rotated video before processing. Supports common video formats like MP4, WebM, and OGG.',
shortDescription: 'Rotate videos by 90, 180, or 270 degrees',
keywords: ['rotate', 'video', 'flip', 'edit', 'adjust'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,44 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
const ffmpeg = new FFmpeg();
export async function rotateVideo(
input: File,
rotation: number
): 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 rotateMap: Record<number, string> = {
90: 'transpose=1',
180: 'transpose=2,transpose=2',
270: 'transpose=2',
0: ''
};
const rotateFilter = rotateMap[rotation];
const args = ['-i', inputName];
if (rotateFilter) {
args.push('-vf', rotateFilter);
}
args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
await ffmpeg.exec(args);
const rotatedData = await ffmpeg.readFile(outputName);
return new File(
[new Blob([rotatedData], { type: 'video/mp4' })],
`${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`,
{ type: 'video/mp4' }
);
}

View File

@@ -21,6 +21,7 @@ export type ToolCategory =
| 'video'
| 'list'
| 'json'
| 'time'
| 'csv'
| 'time'
| 'pdf';

View File

@@ -66,6 +66,12 @@ const categoriesConfig: {
value:
'Tools for working with JSON data structures prettify and minify JSON objects, flatten JSON arrays, stringify JSON values, analyze data, and much more'
},
{
type: 'time',
icon: 'mdi:clock-time-five',
value:
'Tools for working with time and date calculate time differences, convert between time zones, format dates, generate date sequences, and much more.'
},
{
type: 'csv',
icon: 'material-symbols-light:csv-outline',

View File

@@ -6,6 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config https://vitest.dev/config
export default defineConfig({
plugins: [react(), tsconfigPaths()],
define: {
'process.env': {}
},
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
},