mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-19 05:59:34 +02:00
Merge branch 'main' into json-escaper-#49
This commit is contained in:
268
.idea/workspace.xml
generated
268
.idea/workspace.xml
generated
@@ -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 @@
|
||||
"state": "OPEN"
|
||||
}
|
||||
}</component>
|
||||
<component name="GitHubPullRequestState">{
|
||||
"prStates": [
|
||||
<component name="GitHubPullRequestState"><![CDATA[{
|
||||
"prStates": [
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts51PkS9",
|
||||
"number": 22
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts51PkS9",
|
||||
"number": 22
|
||||
},
|
||||
"lastSeen": 1741207144695
|
||||
"lastSeen": 1741207144695
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6NiNYl",
|
||||
"number": 32
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6NiNYl",
|
||||
"number": 32
|
||||
},
|
||||
"lastSeen": 1741209723869
|
||||
"lastSeen": 1741209723869
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6Nheyd",
|
||||
"number": 31
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6Nheyd",
|
||||
"number": 31
|
||||
},
|
||||
"lastSeen": 1741213371410
|
||||
"lastSeen": 1741213371410
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6NmRBs",
|
||||
"number": 33
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6NmRBs",
|
||||
"number": 33
|
||||
},
|
||||
"lastSeen": 1741282429036
|
||||
"lastSeen": 1741282429036
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts5zyFTs",
|
||||
"number": 15
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts5zyFTs",
|
||||
"number": 15
|
||||
},
|
||||
"lastSeen": 1741535540953
|
||||
"lastSeen": 1741535540953
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QQB3c",
|
||||
"number": 59
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QQB3c",
|
||||
"number": 59
|
||||
},
|
||||
"lastSeen": 1743018960900
|
||||
"lastSeen": 1743018960900
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QMPEg",
|
||||
"number": 58
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QMPEg",
|
||||
"number": 58
|
||||
},
|
||||
"lastSeen": 1743019452983
|
||||
"lastSeen": 1743019452983
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QZvRI",
|
||||
"number": 61
|
||||
},
|
||||
"lastSeen": 1743103196866
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QqPrQ",
|
||||
"number": 73
|
||||
},
|
||||
"lastSeen": 1743265865001
|
||||
}
|
||||
]
|
||||
}</component>
|
||||
}]]></component>
|
||||
<component name="GithubPullRequestsUISettings">{
|
||||
"selectedUrlAndAccountId": {
|
||||
"url": "https://github.com/iib0011/omni-tools.git",
|
||||
@@ -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">{
|
||||
"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",
|
||||
"Vitest.timeBetweenDates.executor": "Run",
|
||||
"git-widget-placeholder": "main",
|
||||
"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>
|
||||
}</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 />
|
||||
|
10
README.md
10
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
BIN
img.png
Binary file not shown.
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 122 KiB |
20
src/components/BackButton.tsx
Normal file
20
src/components/BackButton.tsx
Normal 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;
|
@@ -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}>
|
||||
<Typography
|
||||
fontSize={22}
|
||||
color={theme.palette.primary.main}
|
||||
>{`All ${capitalizeFirstLetter(categoryName)} Tools`}</Typography>
|
||||
<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)
|
||||
|
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
156
src/pages/tools/csv/csv-rows-to-columns/index.tsx
Normal file
156
src/pages/tools/csv/csv-rows-to-columns/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
15
src/pages/tools/csv/csv-rows-to-columns/meta.ts
Normal file
15
src/pages/tools/csv/csv-rows-to-columns/meta.ts
Normal 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'))
|
||||
});
|
40
src/pages/tools/csv/csv-rows-to-columns/service.ts
Normal file
40
src/pages/tools/csv/csv-rows-to-columns/service.ts
Normal 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');
|
||||
}
|
@@ -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];
|
||||
|
@@ -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
|
||||
];
|
||||
|
250
src/pages/tools/time/time-between-dates/index.tsx
Normal file
250
src/pages/tools/time/time-between-dates/index.tsx
Normal 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'
|
||||
}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
22
src/pages/tools/time/time-between-dates/meta.ts
Normal file
22
src/pages/tools/time/time-between-dates/meta.ts
Normal 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'))
|
||||
});
|
129
src/pages/tools/time/time-between-dates/service.ts
Normal file
129
src/pages/tools/time/time-between-dates/service.ts
Normal 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);
|
||||
};
|
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
@@ -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];
|
||||
|
111
src/pages/tools/video/rotate/index.tsx
Normal file
111
src/pages/tools/video/rotate/index.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
13
src/pages/tools/video/rotate/meta.ts
Normal file
13
src/pages/tools/video/rotate/meta.ts
Normal 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'))
|
||||
});
|
44
src/pages/tools/video/rotate/service.ts
Normal file
44
src/pages/tools/video/rotate/service.ts
Normal 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' }
|
||||
);
|
||||
}
|
@@ -21,6 +21,7 @@ export type ToolCategory =
|
||||
| 'video'
|
||||
| 'list'
|
||||
| 'json'
|
||||
| 'time'
|
||||
| 'csv'
|
||||
| 'time'
|
||||
| 'pdf';
|
||||
|
@@ -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',
|
||||
|
@@ -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']
|
||||
},
|
||||
|
Reference in New Issue
Block a user