Merge branch 'main' into epoch-converter

This commit is contained in:
Aashish Anand 2025-07-11 14:56:21 -07:00 committed by GitHub
commit 166771d638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 3150 additions and 344 deletions

547
.idea/workspace.xml generated
View file

@ -4,10 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="fix:">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="docs: edit pdf meta">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/time/crontab-guru/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/time/crontab-guru/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/time/crontab-guru/meta.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/time/crontab-guru/meta.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/ToolContent.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolContent.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/number/generic-calc/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/number/generic-calc/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/time/check-leap-years/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/time/check-leap-years/index.tsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -21,7 +23,7 @@
<option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="fork/y1hao/dark" />
<entry key="$PROJECT_DIR$" value="chesterking" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@ -40,178 +42,171 @@
&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
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QZvRI&quot;,
&quot;number&quot;: 61
"id": {
"id": "PR_kwDOMJIfts6QZvRI",
"number": 61
},
&quot;lastSeen&quot;: 1743103196866
"lastSeen": 1743103196866
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QqPrQ&quot;,
&quot;number&quot;: 73
"id": {
"id": "PR_kwDOMJIfts6QqPrQ",
"number": 73
},
&quot;lastSeen&quot;: 1743265865001
"lastSeen": 1743265865001
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Qp5nI&quot;,
&quot;number&quot;: 72
"id": {
"id": "PR_kwDOMJIfts6Qp5nI",
"number": 72
},
&quot;lastSeen&quot;: 1743338472110
"lastSeen": 1743338472110
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QsjlS&quot;,
&quot;number&quot;: 76
"id": {
"id": "PR_kwDOMJIfts6QsjlS",
"number": 76
},
&quot;lastSeen&quot;: 1743352150953
"lastSeen": 1743352150953
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Q0JBe&quot;,
&quot;number&quot;: 82
"id": {
"id": "PR_kwDOMJIfts6Q0JBe",
"number": 82
},
&quot;lastSeen&quot;: 1743470267269
"lastSeen": 1743470267269
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6UE9-x&quot;,
&quot;number&quot;: 102
"id": {
"id": "PR_kwDOMJIfts6UE9-x",
"number": 102
},
&quot;lastSeen&quot;: 1747171977348
"lastSeen": 1747171977348
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6XPua_&quot;,
&quot;number&quot;: 117
"id": {
"id": "PR_kwDOMJIfts6XPua_",
"number": 117
},
&quot;lastSeen&quot;: 1747929835864
"lastSeen": 1747929835864
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6XY-mZ&quot;,
&quot;number&quot;: 119
"id": {
"id": "PR_kwDOMJIfts6XY-mZ",
"number": 119
},
&quot;lastSeen&quot;: 1748028108508
"lastSeen": 1748028108508
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Xdz4n&quot;,
&quot;number&quot;: 120
"id": {
"id": "PR_kwDOMJIfts6Xdz4n",
"number": 120
},
&quot;lastSeen&quot;: 1748282672214
"lastSeen": 1748282672214
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6X_zxl&quot;,
&quot;number&quot;: 131
"id": {
"id": "PR_kwDOMJIfts6X_zxl",
"number": 131
},
&quot;lastSeen&quot;: 1748881279494
"lastSeen": 1748881279494
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6bhieT&quot;,
&quot;number&quot;: 152
"id": {
"id": "PR_kwDOMJIfts6bhieT",
"number": 152
},
&quot;lastSeen&quot;: 1751848489082
"lastSeen": 1751848489082
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6dOyRk&quot;,
&quot;number&quot;: 154
"id": {
"id": "PR_kwDOMJIfts6dOyRk",
"number": 154
},
&quot;lastSeen&quot;: 1751849436454
"lastSeen": 1751849436454
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6cHjNi&quot;,
&quot;number&quot;: 153
"id": {
"id": "PR_kwDOMJIfts6cHjNi",
"number": 153
},
&quot;lastSeen&quot;: 1751849501498
"lastSeen": 1751849501498
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6Zs1FN&quot;,
&quot;number&quot;: 145
"id": {
"id": "PR_kwDOMJIfts6Zs1FN",
"number": 145
},
&quot;lastSeen&quot;: 1751849770308
"lastSeen": 1751849770308
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6bgKi9&quot;,
&quot;number&quot;: 150
"id": {
"id": "PR_kwDOMJIfts6bgKi9",
"number": 150
},
&quot;lastSeen&quot;: 1751850367300
"lastSeen": 1751850367300
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6dv21R&quot;,
&quot;number&quot;: 162
"id": {
"id": "PR_kwDOMJIfts6eUKC-",
"number": 176
},
&quot;lastSeen&quot;: 1751893739514
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6dwQJi&quot;,
&quot;number&quot;: 163
},
&quot;lastSeen&quot;: 1751893861615
"lastSeen": 1752158748013
}
]
}</component>
}]]></component>
<component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;,
@ -243,56 +238,56 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<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;#167 on fork/AshAnand34/crontab-guru-tool&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/pages/tools/json&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 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",
"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/public",
"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>
@ -304,11 +299,11 @@
</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets\fonts\quicksand" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets\fonts" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\tools\json" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\components\input" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\lib\ghostscript" />
@ -319,19 +314,6 @@
</key>
</component>
<component name="RunManager" selected="npm.dev">
<configuration name="calculateTimeBetweenDates" 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="calculateTimeBetweenDates" />
</test-names>
<method v="2" />
</configuration>
<configuration name="parsePageRanges" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
@ -388,9 +370,19 @@
<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="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" />
@ -401,7 +393,7 @@
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="npm.test:e2e" />
</list>
</recent_temporary>
</component>
@ -507,88 +499,11 @@
<workItem from="1748282636141" duration="478000" />
<workItem from="1749047510481" duration="879000" />
<workItem from="1751846528195" duration="4358000" />
<workItem from="1751852868038" duration="680000" />
<workItem from="1751893034799" duration="1192000" />
</task>
<task id="LOCAL-00159" summary="refactor: sum">
<option name="closed" value="true" />
<created>1741544086061</created>
<option name="number" value="00159" />
<option name="presentableId" value="LOCAL-00159" />
<option name="project" value="LOCAL" />
<updated>1741544086061</updated>
</task>
<task id="LOCAL-00160" summary="fix: tools by category scroll">
<option name="closed" value="true" />
<created>1741548044897</created>
<option name="number" value="00160" />
<option name="presentableId" value="LOCAL-00160" />
<option name="project" value="LOCAL" />
<updated>1741548044897</updated>
</task>
<task id="LOCAL-00161" summary="fix: missing meta">
<option name="closed" value="true" />
<created>1741568170877</created>
<option name="number" value="00161" />
<option name="presentableId" value="LOCAL-00161" />
<option name="project" value="LOCAL" />
<updated>1741568170877</updated>
</task>
<task id="LOCAL-00162" summary="feat: trim video">
<option name="closed" value="true" />
<created>1741580004784</created>
<option name="number" value="00162" />
<option name="presentableId" value="LOCAL-00162" />
<option name="project" value="LOCAL" />
<updated>1741580004784</updated>
</task>
<task id="LOCAL-00163" summary="feat: trim video">
<option name="closed" value="true" />
<created>1741580736359</created>
<option name="number" value="00163" />
<option name="presentableId" value="LOCAL-00163" />
<option name="project" value="LOCAL" />
<updated>1741580736359</updated>
</task>
<task id="LOCAL-00164" summary="refactor: file inputs">
<option name="closed" value="true" />
<created>1742960931740</created>
<option name="number" value="00164" />
<option name="presentableId" value="LOCAL-00164" />
<option name="project" value="LOCAL" />
<updated>1742960931740</updated>
</task>
<task id="LOCAL-00165" summary="feat: background removal">
<option name="closed" value="true" />
<created>1742961898820</created>
<option name="number" value="00165" />
<option name="presentableId" value="LOCAL-00165" />
<option name="project" value="LOCAL" />
<updated>1742961898820</updated>
</task>
<task id="LOCAL-00166" summary="feat: split pdf">
<option name="closed" value="true" />
<created>1742967844908</created>
<option name="number" value="00166" />
<option name="presentableId" value="LOCAL-00166" />
<option name="project" value="LOCAL" />
<updated>1742967844908</updated>
</task>
<task id="LOCAL-00167" summary="fix: typo">
<option name="closed" value="true" />
<created>1743019312699</created>
<option name="number" value="00167" />
<option name="presentableId" value="LOCAL-00167" />
<option name="project" value="LOCAL" />
<updated>1743019312699</updated>
</task>
<task id="LOCAL-00168" summary="chore: result file name">
<option name="closed" value="true" />
<created>1743020690384</created>
<option name="number" value="00168" />
<option name="presentableId" value="LOCAL-00168" />
<option name="project" value="LOCAL" />
<updated>1743020690384</updated>
<workItem from="1752070315115" duration="19000" />
<workItem from="1752071020011" duration="1599000" />
<workItem from="1752077170501" duration="4261000" />
<workItem from="1752127185450" duration="1168000" />
<workItem from="1752157409587" duration="2415000" />
</task>
<task id="LOCAL-00169" summary="chore: text result extensions">
<option name="closed" value="true" />
@ -886,23 +801,103 @@
<option name="project" value="LOCAL" />
<updated>1751850152784</updated>
</task>
<task id="LOCAL-00206" summary="chore: remove .codebuddy">
<task id="LOCAL-00206" summary="chore: new logo and font">
<option name="closed" value="true" />
<created>1751852942341</created>
<created>1752022118195</created>
<option name="number" value="00206" />
<option name="presentableId" value="LOCAL-00206" />
<option name="project" value="LOCAL" />
<updated>1751852942341</updated>
<updated>1752022118199</updated>
</task>
<task id="LOCAL-00207" summary="chore: add array key">
<task id="LOCAL-00207" summary="chore: white logo">
<option name="closed" value="true" />
<created>1751893722720</created>
<created>1752022731608</created>
<option name="number" value="00207" />
<option name="presentableId" value="LOCAL-00207" />
<option name="project" value="LOCAL" />
<updated>1751893722720</updated>
<updated>1752022731608</updated>
</task>
<option name="localTasksCounter" value="208" />
<task id="LOCAL-00208" summary="chore: png icon">
<option name="closed" value="true" />
<created>1752023182341</created>
<option name="number" value="00208" />
<option name="presentableId" value="LOCAL-00208" />
<option name="project" value="LOCAL" />
<updated>1752023182341</updated>
</task>
<task id="LOCAL-00209" summary="fix: remove xml viewer">
<option name="closed" value="true" />
<created>1752023796004</created>
<option name="number" value="00209" />
<option name="presentableId" value="LOCAL-00209" />
<option name="project" value="LOCAL" />
<updated>1752023796004</updated>
</task>
<task id="LOCAL-00210" summary="feat: convert to jpg">
<option name="closed" value="true" />
<created>1752026153328</created>
<option name="number" value="00210" />
<option name="presentableId" value="LOCAL-00210" />
<option name="project" value="LOCAL" />
<updated>1752026153328</updated>
</task>
<task id="LOCAL-00211" summary="feat: edit image">
<option name="closed" value="true" />
<created>1752032092273</created>
<option name="number" value="00211" />
<option name="presentableId" value="LOCAL-00211" />
<option name="project" value="LOCAL" />
<updated>1752032092274</updated>
</task>
<task id="LOCAL-00212" summary="fix: favicons">
<option name="closed" value="true" />
<created>1752071147050</created>
<option name="number" value="00212" />
<option name="presentableId" value="LOCAL-00212" />
<option name="project" value="LOCAL" />
<updated>1752071147050</updated>
</task>
<task id="LOCAL-00213" summary="chore: examples button visibility">
<option name="closed" value="true" />
<created>1752079671580</created>
<option name="number" value="00213" />
<option name="presentableId" value="LOCAL-00213" />
<option name="project" value="LOCAL" />
<updated>1752079671580</updated>
</task>
<task id="LOCAL-00214" summary="feat: pdf editor">
<option name="closed" value="true" />
<created>1752079879005</created>
<option name="number" value="00214" />
<option name="presentableId" value="LOCAL-00214" />
<option name="project" value="LOCAL" />
<updated>1752079879005</updated>
</task>
<task id="LOCAL-00215" summary="chore: style link">
<option name="closed" value="true" />
<created>1752080307348</created>
<option name="number" value="00215" />
<option name="presentableId" value="LOCAL-00215" />
<option name="project" value="LOCAL" />
<updated>1752080307349</updated>
</task>
<task id="LOCAL-00216" summary="refactor: PDF editor">
<option name="closed" value="true" />
<created>1752157851370</created>
<option name="number" value="00216" />
<option name="presentableId" value="LOCAL-00216" />
<option name="project" value="LOCAL" />
<updated>1752157851371</updated>
</task>
<task id="LOCAL-00217" summary="docs: edit pdf meta">
<option name="closed" value="true" />
<created>1752158119802</created>
<option name="number" value="00217" />
<option name="presentableId" value="LOCAL-00217" />
<option name="project" value="LOCAL" />
<updated>1752158119802</updated>
</task>
<option name="localTasksCounter" value="218" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -949,16 +944,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="refactor: compress pdf" />
<MESSAGE value="refactor: lib" />
<MESSAGE value="fix: path" />
<MESSAGE value="fix: vite worker format" />
<MESSAGE value="fix: tests" />
<MESSAGE value="chore: uninstall @jspawn/ghostscript-wasm" />
<MESSAGE value="feat: protect pdf" />
<MESSAGE value="feat: image to text" />
<MESSAGE value="chore: hideCopy if video or audio" />
<MESSAGE value="chore: readme img and fix broken link" />
<MESSAGE value="fix: add mkv to supported videos" />
<MESSAGE value="feat: drag and drop" />
<MESSAGE value="Merge branch 'feat/pdf-merge' of git-rohit:rohit267/omni-tools into feat/pdf-merge" />
@ -972,9 +957,19 @@
<MESSAGE value="chore: use scrollY" />
<MESSAGE value="chore: remove flip x and y" />
<MESSAGE value="fix: tsc" />
<MESSAGE value="chore: remove .codebuddy" />
<MESSAGE value="chore: add array key" />
<option name="LAST_COMMIT_MESSAGE" value="chore: add array key" />
<MESSAGE value="chore: new logo and font" />
<MESSAGE value="chore: white logo" />
<MESSAGE value="chore: png icon" />
<MESSAGE value="fix: remove xml viewer" />
<MESSAGE value="feat: convert to jpg" />
<MESSAGE value="feat: edit image" />
<MESSAGE value="fix: favicons" />
<MESSAGE value="chore: examples button visibility" />
<MESSAGE value="feat: pdf editor" />
<MESSAGE value="chore: style link" />
<MESSAGE value="refactor: PDF editor" />
<MESSAGE value="docs: edit pdf meta" />
<option name="LAST_COMMIT_MESSAGE" value="docs: edit pdf meta" />
</component>
<component name="VgoProject">
<integration-enabled>false</integration-enabled>

View file

@ -1,10 +1,10 @@
<div align="center">
<img src="src/assets/logo.png" width="300" />
<img src="src/assets/logo.png" width="220" />
<br /><br />
<a href="https://trendshift.io/repositories/13055" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13055" alt="iib0011%2Fomni-tools | Trendshift" style="width: 200px;" width="200"/></a>
<br /><br />
<a href="https://github.com/iib0011/omni-tools/releases">
<img src="https://img.shields.io/badge/version-0.4.0-blue?style=for-the-badge" />
<img src="https://img.shields.io/badge/version-0.5.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" />
@ -44,15 +44,23 @@ Plus, the Docker image is super lightweight at just 28MB, making it fast to depl
We strive to offer a variety of tools, including:
## **Image/Video/Binary Tools**
## **Image/Video/Audio Tools**
- Image Resizer
- Image Converter
- Image Editor
- Video Trimmer
- Video Reverser
- And more...
## **String/List Tools**
## **PDF Tools**
- PDF Splitter
- PDF Merger
- PDF Editor
- And more...
## **Text/List Tools**
- Case Converters
- List Shuffler
@ -68,14 +76,14 @@ We strive to offer a variety of tools, including:
## **Math Tools**
- Generate Prime Numbers
- Generate Perfect Numbers
- Calculate voltage, current, or resistance
- And more...
## **Miscellaneous Tools**
## **Data Tools**
- JSON Tools
- PDF Tools
- CSV Tools
- XML Tools
- And more...
Stay tuned as we continue to expand and improve our collection!
@ -147,7 +155,7 @@ npm run test:e2e
We welcome contributions! You can help by:
- ✅ Reporting bugs
- ✅ Suggesting new features in Github issues or [here](https://tally.so/r/nrkkx2)
- ✅ Suggesting new features in GitHub issues or [here](https://tally.so/r/nrkkx2)
- ✅ Improving documentation
- ✅ Submitting pull requests

BIN
img.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

View file

@ -2,8 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link href="/assets/fonts/plus-jakarta/plus-jakarta.css" rel="stylesheet" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="OmniTools" />
<link rel="manifest" href="/site.webmanifest" />
<link href="/assets/fonts/quicksand/quick-sand.css" rel="stylesheet" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OmniTools</title>
</head>

383
package-lock.json generated
View file

@ -18,6 +18,7 @@
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@playwright/test": "^1.45.0",
"@simplepdf/react-embed-pdf": "^1.9.0",
"@types/ffmpeg": "^1.0.7",
"@types/js-quantities": "^1.6.6",
"@types/lodash": "^4.17.5",
@ -29,6 +30,7 @@
"cron-validator": "^1.3.1",
"cronstrue": "^3.0.0",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.2.5",
"formik": "^2.4.6",
"jimp": "^0.22.12",
"js-quantities": "^1.8.0",
@ -47,8 +49,10 @@
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-filerobot-image-editor": "^4.9.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-konva": "^18.2.10",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",
@ -2905,6 +2909,45 @@
"win32"
]
},
"node_modules/@scaleflex/icons": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/@scaleflex/icons/-/icons-2.10.27.tgz",
"integrity": "sha512-3E/tqXQrsuFIeGwDHE/ANEdDCPCYrt3ETk3/Q83M5ZZaFWdFWJG3bMeVBwNP2Nuul5OMr70LH3ce3krEObz98g==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@types/react": ">=16.0.0",
"@types/react-dom": ">=16.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@scaleflex/ui": {
"version": "2.10.27",
"resolved": "https://registry.npmjs.org/@scaleflex/ui/-/ui-2.10.27.tgz",
"integrity": "sha512-Id9EJjS4NWGn9V0pZRCk8YpM2PVEK8/a/BtTbgEW5L7wPI/APmZ9vGtCTM3HyTEBrfnvWmDlb0T5CfpozywKyA==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.6.0",
"@scaleflex/icons": "^2.10.27",
"@tippyjs/react": "^4.2.6",
"@types/lodash.merge": "^4.6.9",
"lodash.merge": "^4.6.2",
"prop-types": "^15.7.2"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"@types/react": ">=16.0.0",
"@types/react-dom": ">=16.0.0",
"react": ">=16.0.0",
"react-dom": ">=16.0.0",
"styled-components": ">=5.0.0"
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@ -2926,6 +2969,16 @@
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
"dev": true
},
"node_modules/@simplepdf/react-embed-pdf": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@simplepdf/react-embed-pdf/-/react-embed-pdf-1.9.0.tgz",
"integrity": "sha512-qp0K5Fh8E+Zk7m3vyHtvksNlozOUyYRG2wR/TiEjhNh12kj+ar4N+1rEJA7BOsf/M2HnSplwjNOPuci2CKkKGg==",
"license": "MIT",
"peerDependencies": {
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@ -3258,6 +3311,19 @@
"react-dom": "^18.0.0"
}
},
"node_modules/@tippyjs/react": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz",
"integrity": "sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.1"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@ -3345,6 +3411,15 @@
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.5.tgz",
"integrity": "sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw=="
},
"node_modules/@types/lodash.merge": {
"version": "4.6.9",
"resolved": "https://registry.npmjs.org/@types/lodash.merge/-/lodash.merge-4.6.9.tgz",
"integrity": "sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/morsee": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/morsee/-/morsee-1.0.2.tgz",
@ -3396,7 +3471,6 @@
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
@ -3410,6 +3484,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-reconciler": {
"version": "0.28.9",
"resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
"integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
@ -3424,6 +3507,13 @@
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/stylis": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz",
"integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==",
"license": "MIT",
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
@ -4405,6 +4495,16 @@
"node": ">= 6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001636",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
@ -4849,6 +4949,28 @@
"node": ">= 8"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
"integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=4"
}
},
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
"integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"camelize": "^1.0.0",
"css-color-keywords": "^1.0.0",
"postcss-value-parser": "^4.0.2"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@ -5893,6 +6015,24 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@ -7189,6 +7329,18 @@
"set-function-name": "^2.0.1"
}
},
"node_modules/its-fine": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
"integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.0"
},
"peerDependencies": {
"react": ">=18.0"
}
},
"node_modules/jackspeak": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz",
@ -7421,6 +7573,27 @@
"json-buffer": "3.0.1"
}
},
"node_modules/konva": {
"version": "9.3.22",
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz",
"integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"peer": true
},
"node_modules/lazy-ass": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
@ -7734,8 +7907,7 @@
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/lodash.mergewith": {
"version": "4.6.2",
@ -8151,7 +8323,6 @@
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
@ -8727,9 +8898,10 @@
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@ -8869,10 +9041,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"dev": true,
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"funding": [
{
"type": "opencollective",
@ -8887,10 +9058,11 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -9031,8 +9203,7 @@
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/prelude-ls": {
"version": "1.2.1",
@ -9436,6 +9607,62 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
"node_modules/react-filerobot-image-editor": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/react-filerobot-image-editor/-/react-filerobot-image-editor-4.9.1.tgz",
"integrity": "sha512-O9xFySHT6MKuNXAKJMVGG2wyMeaV9NxHIVyBWzhysdbaxx7fZO0r4aQsBFkYt7+0B3Se5/33Sv90r8t3274Q+w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.2",
"@scaleflex/icons": "2.10.27",
"@scaleflex/ui": "2.10.27",
"konva": "9.3.6",
"prop-types": "15.7.2"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0",
"react-konva": ">=17.0.0",
"styled-components": ">=5.3.5"
}
},
"node_modules/react-filerobot-image-editor/node_modules/konva": {
"version": "9.3.6",
"resolved": "https://registry.npmjs.org/konva/-/konva-9.3.6.tgz",
"integrity": "sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
},
"node_modules/react-filerobot-image-editor/node_modules/prop-types": {
"version": "15.7.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz",
"integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.8.1"
}
},
"node_modules/react-filerobot-image-editor/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-helmet": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
@ -9470,6 +9697,53 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/react-konva": {
"version": "18.2.10",
"resolved": "https://registry.npmjs.org/react-konva/-/react-konva-18.2.10.tgz",
"integrity": "sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/lavrton"
},
{
"type": "opencollective",
"url": "https://opencollective.com/konva"
},
{
"type": "github",
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT",
"dependencies": {
"@types/react-reconciler": "^0.28.2",
"its-fine": "^1.1.1",
"react-reconciler": "~0.29.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"konva": "^8.0.1 || ^7.2.5 || ^9.0.0",
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/react-reconciler": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz",
"integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-router": {
"version": "6.23.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz",
@ -9936,6 +10210,13 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT",
"peer": true
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -10075,10 +10356,10 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"dev": true,
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@ -10502,6 +10783,18 @@
"integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==",
"dev": true
},
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/strtok3": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
@ -10518,6 +10811,49 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/styled-components": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.19.tgz",
"integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/is-prop-valid": "1.2.2",
"@emotion/unitless": "0.8.1",
"@types/stylis": "4.2.5",
"css-to-react-native": "3.2.0",
"csstype": "3.1.3",
"postcss": "8.4.49",
"shallowequal": "1.1.0",
"stylis": "4.3.2",
"tslib": "2.6.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/styled-components"
},
"peerDependencies": {
"react": ">= 16.8.0",
"react-dom": ">= 16.8.0"
}
},
"node_modules/styled-components/node_modules/stylis": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz",
"integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==",
"license": "MIT",
"peer": true
},
"node_modules/styled-components/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD",
"peer": true
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
@ -10795,6 +11131,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",

View file

@ -35,6 +35,7 @@
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"@playwright/test": "^1.45.0",
"@simplepdf/react-embed-pdf": "^1.9.0",
"@types/ffmpeg": "^1.0.7",
"@types/js-quantities": "^1.6.6",
"@types/lodash": "^4.17.5",
@ -46,6 +47,7 @@
"cron-validator": "^1.3.1",
"cronstrue": "^3.0.0",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.2.5",
"formik": "^2.4.6",
"jimp": "^0.22.12",
"js-quantities": "^1.8.0",
@ -64,8 +66,10 @@
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-filerobot-image-editor": "^4.9.1",
"react-helmet": "^6.1.0",
"react-image-crop": "^11.0.7",
"react-konva": "^18.2.10",
"react-router-dom": "^6.23.1",
"tesseract.js": "^6.0.0",
"type-fest": "^4.35.0",

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,17 +1,17 @@
@font-face {
font-family: "Plus Jakarta Sans";
font-family: "Quicksand";
font-weight: 100 900;
font-display: swap;
font-style: normal;
font-named-instance: "Regular";
src: url("PlusJakartaSans-VariableFont_wght.ttf");
src: url("Quicksand-VariableFont_wght.ttf");
}
@font-face {
font-family: "Plus Jakarta Sans";
font-family: "Quicksand";
font-weight: 100 900;
font-display: swap;
font-style: italic;
font-named-instance: "Italic";
src: url("PlusJakartaSans-Italic-VariableFont_wght.ttf");
src: url("quicksand-italic.ttf");
}

Binary file not shown.

BIN
public/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

21
public/site.webmanifest Normal file
View file

@ -0,0 +1,21 @@
{
"name": "OmniTools",
"short_name": "OmniTools",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
src/assets/logo-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

@ -1,3 +0,0 @@
<svg width="410" height="410" viewBox="0 0 410 410" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M205 410C318.218 410 410 318.218 410 205C410 91.7816 318.218 0 205 0C91.7816 0 0 91.7816 0 205C0 318.218 91.7816 410 205 410ZM205 360C290.604 360 360 290.604 360 205C360 119.396 290.604 50 205 50C119.396 50 50 119.396 50 205C50 290.604 119.396 360 205 360Z" fill="#4F46E5" />
</svg>

Before

Width:  |  Height:  |  Size: 434 B

View file

@ -6,6 +6,7 @@ import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';
import { Link, useNavigate } from 'react-router-dom';
import logo from 'assets/logo.png';
import logoWhite from 'assets/logo-white.png';
import {
Drawer,
List,
@ -107,17 +108,22 @@ const Navbar: React.FC<NavbarProps> = ({
sx={{
background: 'transparent',
boxShadow: 'none',
color: 'text.primary'
color: 'text.primary',
pt: 2
}}
>
<Toolbar
sx={{
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
mx: { md: '50px', lg: '150px' }
}}
>
<Link to="/">
<img src={logo} width={isMobile ? '80px' : '150px'} />
<img
src={theme.palette.mode === 'light' ? logo : logoWhite}
width={isMobile ? '120px' : '200px'}
/>
</Link>
{isMobile ? (
<>

View file

@ -46,6 +46,9 @@ interface ToolContentProps<Options, Input> extends ToolComponentProps {
setFieldValue: (fieldName: string, value: any) => void
) => ReactNode;
initialValues: Options;
/**
* should return non-empty array or null
*/
getGroups: GetGroupsType<Options> | null;
compute: (optionsValues: Options, input: Input) => void;
toolInfo?: {

View file

@ -6,6 +6,7 @@ import Grid from '@mui/material/Grid';
import { Icon, IconifyIcon } from '@iconify/react';
import { categoriesColors } from '../config/uiConfig';
import { getToolsByCategory } from '@tools/index';
import { useEffect, useState } from 'react';
const StyledButton = styled(Button)(({ theme }) => ({
backgroundColor: 'white',
@ -23,11 +24,25 @@ interface ToolHeaderProps {
}
function ToolLinks() {
const theme = useTheme();
const [examplesVisible, setExamplesVisible] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
const element = document.getElementById('examples');
if (element && isVisible(element)) {
setExamplesVisible(true);
}
}, 500);
return () => clearTimeout(timeout);
}, []);
const scrollToElement = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
};
function isVisible(elm: HTMLElement | null) {
return !!elm;
}
return (
<Grid container spacing={2} mt={1}>
<Grid item md={12} lg={6}>
@ -40,16 +55,18 @@ function ToolLinks() {
Use This Tool
</StyledButton>
</Grid>
<Grid item md={12} lg={6}>
<StyledButton
fullWidth
variant="outlined"
sx={{ backgroundColor: 'background.paper' }}
onClick={() => scrollToElement('examples')}
>
See Examples
</StyledButton>
</Grid>
{examplesVisible && (
<Grid item md={12} lg={6}>
<StyledButton
fullWidth
variant="outlined"
sx={{ backgroundColor: 'background.paper' }}
onClick={() => scrollToElement('examples')}
>
See Examples
</StyledButton>
</Grid>
)}
{/*<Grid item md={12} lg={4}>*/}
{/* <StyledButton fullWidth variant="outlined" href="#tour">*/}
{/* Learn How to Use*/}

View file

@ -12,7 +12,7 @@ export default function ToolInputAndResult({
return (
<Grid id="tool" container spacing={2}>
{input && (
<Grid item xs={12} md={6}>
<Grid item xs={12} md={result ? 6 : 12}>
{input}
</Grid>
)}

View file

@ -7,5 +7,5 @@ a:hover {
}
* {
font-family: Plus Jakarta Sans, sans-serif;
font-family: Quicksand,sans-serif!important;
}

View file

@ -0,0 +1,46 @@
import React, { useRef } from 'react';
import { Box, Typography } from '@mui/material';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps } from './file-input-utils';
interface AudioFileInputProps extends Omit<BaseFileInputProps, 'accept'> {
accept?: string[];
}
export default function ToolAudioInput({
accept = ['audio/*', '.mp3', '.wav', '.aac'],
...props
}: AudioFileInputProps) {
const audioRef = useRef<HTMLAudioElement>(null);
return (
<BaseFileInput {...props} type={'audio'} accept={accept}>
{({ preview }) => (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}
>
{preview ? (
<audio
ref={audioRef}
src={preview}
style={{ maxWidth: '100%' }}
controls
/>
) : (
<Typography variant="body2" color="textSecondary">
Drag & drop or import an audio file
</Typography>
)}
</Box>
)}
</BaseFileInput>
);
}

View file

@ -0,0 +1,172 @@
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
interface MultiAudioInputComponentProps {
accept: string[];
title?: string;
type: 'audio';
value: MultiAudioInput[];
onChange: (file: MultiAudioInput[]) => void;
}
export interface MultiAudioInput {
file: File;
order: number;
}
export default function ToolMultipleAudioInput({
value,
onChange,
accept,
title,
type
}: MultiAudioInputComponentProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files)
onChange([
...value,
...Array.from(files).map((file) => ({ file, order: value.length }))
]);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
onChange([]);
}
function fileNameTruncate(fileName: string) {
const maxLength = 15;
if (fileName.length > maxLength) {
return fileName.slice(0, maxLength) + '...';
}
return fileName;
}
const sortList = () => {
const list = [...value];
list.sort((a, b) => a.order - b.order);
onChange(list);
};
const reorderList = (sourceIndex: number, destinationIndex: number) => {
if (destinationIndex === sourceIndex) {
return;
}
const list = [...value];
if (destinationIndex === 0) {
list[sourceIndex].order = list[0].order - 1;
sortList();
return;
}
if (destinationIndex === list.length - 1) {
list[sourceIndex].order = list[list.length - 1].order + 1;
sortList();
return;
}
if (destinationIndex < sourceIndex) {
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
sortList();
return;
}
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
sortList();
};
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
/>
<Box
sx={{
width: '100%',
height: '300px',
border: value?.length ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
}}
>
<Box
width="100%"
height="100%"
sx={{
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
position: 'relative'
}}
>
{value?.length ? (
value.map((file, index) => (
<Box
key={index}
sx={{
margin: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '200px',
border: 1,
borderRadius: 1,
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MusicNoteIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
/>
</Box>
);
}

View file

@ -6,10 +6,10 @@ import { FormikProps, FormikValues, useFormikContext } from 'formik';
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
type NonEmptyArray<T> = [T, ...T[]];
export type GetGroupsType<T> = (
formikProps: FormikProps<T> & { updateField: UpdateField<T> }
) => ToolOptionGroup[];
) => NonEmptyArray<ToolOptionGroup>;
export default function ToolOptions<T extends FormikValues>({
children,
@ -50,7 +50,7 @@ export default function ToolOptions<T extends FormikValues>({
<Box mt={2}>
<Stack direction={'row'} spacing={2}>
<ToolOptionGroups
groups={getGroups({ ...formikContext, updateField }) ?? []}
groups={getGroups({ ...formikContext, updateField }) ?? null}
vertical={vertical}
/>
{children}

View file

@ -1,4 +1,11 @@
import { Box, Divider, Stack, TextField, useTheme } from '@mui/material';
import {
Box,
Divider,
Stack,
TextField,
styled,
useTheme
} from '@mui/material';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { Link, useNavigate, useParams } from 'react-router-dom';
@ -15,6 +22,11 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SearchIcon from '@mui/icons-material/Search';
import { Helmet } from 'react-helmet';
const StyledLink = styled(Link)(({ theme }) => ({
'&:hover': {
color: theme.palette.mode === 'dark' ? 'white' : theme.palette.primary.light
}
}));
export default function ToolsByCategory() {
const navigate = useNavigate();
const theme = useTheme();
@ -102,14 +114,14 @@ export default function ToolsByCategory() {
color={categoriesColors[index % categoriesColors.length]}
/>
<Box>
<Link
<StyledLink
style={{
fontSize: 20
}}
to={'/' + tool.path}
>
{tool.name}
</Link>
</StyledLink>
<Typography sx={{ mt: 2 }}>
{tool.shortDescription}
</Typography>

View file

@ -0,0 +1,46 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
vi.mock('@ffmpeg/ffmpeg', () => ({
FFmpeg: vi.fn().mockImplementation(() => ({
loaded: false,
load: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
exec: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { changeAudioSpeed } from './service';
import { InitialValuesType } from './types';
describe('changeAudioSpeed', () => {
it('should return a new File with the correct name and type', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.mp3', {
type: 'audio/mp3'
});
const options: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const result = await changeAudioSpeed(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result?.name).toBe('test-2x.mp3');
expect(result?.type).toBe('audio/mp3');
});
it('should return null if input is null', async () => {
const options: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const result = await changeAudioSpeed(null, options);
expect(result).toBeNull();
});
});

View file

@ -0,0 +1,120 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolAudioInput from '@components/input/ToolAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import RadioWithTextField from '@components/options/RadioWithTextField';
import { changeAudioSpeed } from './service';
const initialValues: InitialValuesType = {
newSpeed: 2,
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function ChangeSpeed({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: InitialValuesType,
input: File | null
) => {
setLoading(true);
try {
const newFile = await changeAudioSpeed(input, optionsValues);
setResult(newFile);
} catch (err) {
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'New Audio Speed',
component: (
<Box>
<TextFieldWithDesc
value={values.newSpeed.toString()}
onOwnChange={(val) => updateField('newSpeed', Number(val))}
description="Default multiplier: 2 means 2x faster"
type="number"
/>
</Box>
)
},
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolAudioInput
value={input}
onChange={setInput}
title={'Input Audio'}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Setting Speed" value={null} loading={true} />
) : (
<ToolFileResult
title="Edited Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View file

@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Change speed',
path: 'change-speed',
icon: 'material-symbols-light:speed-outline',
description:
'This online utility lets you change the speed of an audio. You can speed it up or slow it down.',
shortDescription: 'Quickly change audio speed',
keywords: ['change', 'speed'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,80 @@
import { InitialValuesType } from './types';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
function computeAudioFilter(speed: number): string {
if (speed <= 2 && speed >= 0.5) {
return `atempo=${speed}`;
}
const filters: string[] = [];
let remainingSpeed = speed;
while (remainingSpeed > 2.0) {
filters.push('atempo=2.0');
remainingSpeed /= 2.0;
}
while (remainingSpeed < 0.5) {
filters.push('atempo=0.5');
remainingSpeed /= 0.5;
}
filters.push(`atempo=${remainingSpeed.toFixed(2)}`);
return filters.join(',');
}
export async function changeAudioSpeed(
input: File | null,
options: InitialValuesType
): Promise<File | null> {
if (!input) return null;
const { newSpeed, outputFormat } = options;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
try {
ffmpeg = new FFmpeg();
if (!ffmpegLoaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
ffmpegLoaded = true;
}
const fileName = input.name;
const outputName = `output.${outputFormat}`;
await ffmpeg.writeFile(fileName, await fetchFile(input));
const audioFilter = computeAudioFilter(newSpeed);
let args = ['-i', fileName, '-filter:a', audioFilter];
if (outputFormat === 'mp3') {
args.push('-b:a', '192k', '-f', 'mp3', outputName);
} else if (outputFormat === 'aac') {
args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName);
} else if (outputFormat === 'wav') {
args.push(
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
);
}
await ffmpeg.exec(args);
const data = await ffmpeg.readFile(outputName);
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
const blob = new Blob([data], { type: mimeType });
const newFile = new File(
[blob],
fileName.replace(/\.[^/.]+$/, `-${newSpeed}x.${outputFormat}`),
{ type: mimeType }
);
await ffmpeg.deleteFile(fileName);
await ffmpeg.deleteFile(outputName);
return newFile;
} catch (err) {
console.error(`Failed to process audio: ${err}`);
return null;
}
}

View file

@ -0,0 +1,4 @@
export type InitialValuesType = {
newSpeed: number;
outputFormat: 'mp3' | 'aac' | 'wav';
};

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
// Mock the service module BEFORE importing it
vi.mock('./service', () => ({
extractAudioFromVideo: vi.fn(async (input, options) => {
const ext = options.outputFormat;
return new File([new Blob(['audio data'])], `mock_audio.${ext}`, {
type: `audio/${ext}`
});
})
}));
import { extractAudioFromVideo } from './service';
import { InitialValuesType } from './types';
function createMockVideoFile(): File {
return new File(['video data'], 'test.mp4', { type: 'video/mp4' });
}
describe('extractAudioFromVideo (mocked)', () => {
let videoFile: File;
beforeAll(() => {
videoFile = createMockVideoFile();
});
it('should extract audio as AAC', async () => {
const options: InitialValuesType = { outputFormat: 'aac' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.aac')).toBe(true);
expect(audioFile.type).toBe('audio/aac');
});
it('should extract audio as MP3', async () => {
const options: InitialValuesType = { outputFormat: 'mp3' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.mp3')).toBe(true);
expect(audioFile.type).toBe('audio/mp3');
});
it('should extract audio as WAV', async () => {
const options: InitialValuesType = { outputFormat: 'wav' };
const audioFile = await extractAudioFromVideo(videoFile, options);
expect(audioFile).toBeInstanceOf(File);
expect(audioFile.name.endsWith('.wav')).toBe(true);
expect(audioFile.type).toBe('audio/wav');
});
});

View file

@ -0,0 +1,91 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { extractAudioFromVideo } from './service';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import { GetGroupsType } from '@components/options/ToolOptions';
import ToolFileResult from '@components/result/ToolFileResult';
import SelectWithDesc from '@components/options/SelectWithDesc';
const initialValues: InitialValuesType = {
outputFormat: 'aac'
};
export default function ExtractAudio({
title,
longDescription
}: ToolComponentProps) {
const [file, setFile] = useState<File | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => {
return [
{
title: 'Output Format',
component: (
<Box>
<SelectWithDesc
selected={values.outputFormat}
onChange={(value) => {
updateField('outputFormat', value.toString());
}}
options={[
{ label: 'AAC', value: 'aac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'WAV', value: 'wav' }
]}
description={
'Select the format for the audio to be extracted as.'
}
/>
</Box>
)
}
];
};
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setLoading(true);
const audioFileObj = await extractAudioFromVideo(input, values);
setAudioFile(audioFileObj);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return (
<ToolContent
title={title}
input={file}
inputComponent={
<ToolVideoInput value={file} onChange={setFile} title={'Input Video'} />
}
resultComponent={
loading ? (
<ToolFileResult
title={'Extracting Audio'}
value={null}
loading={true}
/>
) : (
<ToolFileResult title={'Extracted Audio'} value={audioFile} />
)
}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
setInput={setFile}
/>
);
}

View file

@ -0,0 +1,26 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Extract audio',
path: 'extract-audio',
icon: 'mdi:music-note',
description:
'Extract the audio track from a video file and save it as a separate audio file in your chosen format (AAC, MP3, WAV).',
shortDescription:
'Extract audio from video files (MP4, MOV, etc.) to AAC, MP3, or WAV.',
keywords: [
'extract',
'audio',
'video',
'mp3',
'aac',
'wav',
'audio extraction',
'media',
'convert'
],
longDescription:
'This tool allows you to extract the audio track from a video file (such as MP4, MOV, AVI, etc.) and save it as a standalone audio file in your preferred format (AAC, MP3, or WAV). Useful for podcasts, music, or any scenario where you need just the audio from a video.',
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,70 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function extractAudioFromVideo(
input: File,
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp4';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const configuredOutputAudioFormat = options.outputFormat;
const outputName = `output.${configuredOutputAudioFormat}`;
const args: string[] = ['-i', inputName, '-vn'];
if (configuredOutputAudioFormat === 'mp3') {
args.push(
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
);
} else if (configuredOutputAudioFormat === 'wav') {
args.push(
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
);
} else {
// Default to AAC or copy
args.push('-acodec', 'copy', outputName);
}
await ffmpeg.exec(args);
const extractedAudio = await ffmpeg.readFile(outputName);
return new File(
[
new Blob([extractedAudio], {
type: `audio/${configuredOutputAudioFormat}`
})
],
`${input.name.replace(
/\.[^/.]+$/,
''
)}_audio.${configuredOutputAudioFormat}`,
{ type: `audio/${configuredOutputAudioFormat}` }
);
}

View file

@ -0,0 +1,3 @@
export type InitialValuesType = {
outputFormat: string;
};

View file

@ -0,0 +1,11 @@
import { tool as audioMergeAudio } from './merge-audio/meta';
import { tool as audioTrim } from './trim/meta';
import { tool as audioChangeSpeed } from './change-speed/meta';
import { tool as audioExtractAudio } from './extract-audio/meta';
export const audioTools = [
audioExtractAudio,
audioChangeSpeed,
audioTrim,
audioMergeAudio
];

View file

@ -0,0 +1,112 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolMultipleAudioInput, {
MultiAudioInput
} from '@components/input/ToolMultipleAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { mergeAudioFiles } from './service';
const initialValues: InitialValuesType = {
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function MergeAudio({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<MultiAudioInput[]>([]);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: InitialValuesType,
input: MultiAudioInput[]
) => {
if (input.length === 0) return;
setLoading(true);
try {
const files = input.map((item) => item.file);
const mergedFile = await mergeAudioFiles(files, optionsValues);
setResult(mergedFile);
} catch (err) {
console.error(`Failed to merge audio: ${err}`);
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolMultipleAudioInput
value={input}
onChange={setInput}
accept={['audio/*', '.mp3', '.wav', '.aac']}
title={'Input Audio Files'}
type="audio"
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Merging Audio" value={null} loading={true} />
) : (
<ToolFileResult
title="Merged Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View file

@ -0,0 +1,73 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
vi.mock('@ffmpeg/ffmpeg', () => ({
FFmpeg: vi.fn().mockImplementation(() => ({
loaded: false,
load: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
exec: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { mergeAudioFiles } from './service';
describe('mergeAudioFiles', () => {
it('should merge multiple audio files', async () => {
// Create mock audio files
const mockAudioData1 = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockAudioData2 = new Uint8Array([6, 7, 8, 9, 10, 11]);
const mockFile1 = new File([mockAudioData1], 'test1.mp3', {
type: 'audio/mp3'
});
const mockFile2 = new File([mockAudioData2], 'test2.mp3', {
type: 'audio/mp3'
});
const options = {
outputFormat: 'mp3' as const
};
const result = await mergeAudioFiles([mockFile1, mockFile2], options);
expect(result).toBeInstanceOf(File);
expect(result.name).toBe('merged_audio.mp3');
expect(result.type).toBe('audio/mp3');
});
it('should handle different output formats', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.wav', {
type: 'audio/wav'
});
const options = {
outputFormat: 'aac' as const
};
const result = await mergeAudioFiles([mockFile], options);
expect(result).toBeInstanceOf(File);
expect(result.name).toBe('merged_audio.aac');
expect(result.type).toBe('audio/aac');
});
it('should throw error when no input files provided', async () => {
const options = {
outputFormat: 'mp3' as const
};
try {
await mergeAudioFiles([], options);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('No input files provided');
}
});
});

View file

@ -0,0 +1,26 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Merge Audio',
path: 'merge-audio',
icon: 'fluent:merge-20-regular',
description:
'Combine multiple audio files into a single audio file by concatenating them in sequence.',
shortDescription: 'Merge multiple audio files into one (MP3, AAC, WAV).',
keywords: [
'merge',
'audio',
'combine',
'concatenate',
'join',
'mp3',
'aac',
'wav',
'audio editing',
'multiple files'
],
longDescription:
'This tool allows you to merge multiple audio files into a single file by concatenating them in the order you upload them. Perfect for combining podcast segments, music tracks, or any audio files that need to be joined together. Supports various audio formats including MP3, AAC, and WAV.',
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,115 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function mergeAudioFiles(
inputs: File[],
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
if (inputs.length === 0) {
throw new Error('No input files provided');
}
const { outputFormat } = options;
const outputName = `output.${outputFormat}`;
// 1. Convert all inputs to WAV
const tempWavNames: string[] = [];
for (let i = 0; i < inputs.length; i++) {
const inputName = `input${i}`;
const tempWavName = `temp${i}.wav`;
await ffmpeg.writeFile(inputName, await fetchFile(inputs[i]));
await ffmpeg.exec([
'-i',
inputName,
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
tempWavName
]);
tempWavNames.push(tempWavName);
await ffmpeg.deleteFile(inputName);
}
// 2. Create file list for concat
const fileListName = 'filelist.txt';
const fileListContent = tempWavNames
.map((name) => `file '${name}'`)
.join('\n');
await ffmpeg.writeFile(fileListName, fileListContent);
// 3. Concatenate WAV files
const concatWav = 'concat.wav';
await ffmpeg.exec([
'-f',
'concat',
'-safe',
'0',
'-i',
fileListName,
'-c',
'copy',
concatWav
]);
// 4. Convert concatenated WAV to requested output format
let finalOutput = concatWav;
if (outputFormat !== 'wav') {
const args = ['-i', concatWav];
if (outputFormat === 'mp3') {
args.push(
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
);
} else if (outputFormat === 'aac') {
args.push('-c:a', 'aac', '-b:a', '192k', '-f', 'adts', outputName);
}
await ffmpeg.exec(args);
finalOutput = outputName;
}
const mergedAudio = await ffmpeg.readFile(finalOutput);
let mimeType = 'audio/wav';
if (outputFormat === 'mp3') mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
// Clean up files
for (const tempWavName of tempWavNames) {
await ffmpeg.deleteFile(tempWavName);
}
await ffmpeg.deleteFile(fileListName);
await ffmpeg.deleteFile(concatWav);
if (outputFormat !== 'wav') {
await ffmpeg.deleteFile(outputName);
}
return new File(
[
new Blob([mergedAudio], {
type: mimeType
})
],
`merged_audio.${outputFormat}`,
{ type: mimeType }
);
}

View file

@ -0,0 +1,3 @@
export type InitialValuesType = {
outputFormat: 'mp3' | 'aac' | 'wav';
};

View file

@ -0,0 +1,128 @@
import { Box, FormControlLabel, Radio, RadioGroup } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolAudioInput from '@components/input/ToolAudioInput';
import ToolFileResult from '@components/result/ToolFileResult';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { trimAudio } from './service';
const initialValues: InitialValuesType = {
startTime: '00:00:00',
endTime: '00:01:00',
outputFormat: 'mp3'
};
const formatOptions = [
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'WAV', value: 'wav' }
];
export default function Trim({ title, longDescription }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (
optionsValues: InitialValuesType,
input: File | null
) => {
if (!input) return;
setLoading(true);
try {
const trimmedFile = await trimAudio(input, optionsValues);
setResult(trimmedFile);
} catch (err) {
console.error(`Failed to trim audio: ${err}`);
setResult(null);
} finally {
setLoading(false);
}
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Time Settings',
component: (
<Box>
<TextFieldWithDesc
value={values.startTime}
onOwnChange={(val) => updateField('startTime', val)}
description="Start time in format HH:MM:SS (e.g., 00:00:30)"
label="Start Time"
/>
<Box mt={2}>
<TextFieldWithDesc
value={values.endTime}
onOwnChange={(val) => updateField('endTime', val)}
description="End time in format HH:MM:SS (e.g., 00:01:30)"
label="End Time"
/>
</Box>
</Box>
)
},
{
title: 'Output Format',
component: (
<Box mt={2}>
<RadioGroup
row
value={values.outputFormat}
onChange={(e) =>
updateField(
'outputFormat',
e.target.value as 'mp3' | 'aac' | 'wav'
)
}
>
{formatOptions.map((opt) => (
<FormControlLabel
key={opt.value}
value={opt.value}
control={<Radio />}
label={opt.label}
/>
))}
</RadioGroup>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolAudioInput
value={input}
onChange={setInput}
title={'Input Audio'}
/>
}
resultComponent={
loading ? (
<ToolFileResult title="Trimming Audio" value={null} loading={true} />
) : (
<ToolFileResult
title="Trimmed Audio"
value={result}
extension={result ? result.name.split('.').pop() : undefined}
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is ${title}?`, description: longDescription }}
/>
);
}

View file

@ -0,0 +1,27 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('audio', {
name: 'Trim Audio',
path: 'trim',
icon: 'mdi:scissors-cutting',
description:
'Cut and trim audio files to extract specific segments by specifying start and end times.',
shortDescription:
'Trim audio files to extract specific time segments (MP3, AAC, WAV).',
keywords: [
'trim',
'audio',
'cut',
'segment',
'extract',
'mp3',
'aac',
'wav',
'audio editing',
'time'
],
longDescription:
'This tool allows you to trim audio files by specifying start and end times. You can extract specific segments from longer audio files, remove unwanted parts, or create shorter clips. Supports various audio formats including MP3, AAC, and WAV. Perfect for podcast editing, music production, or any audio editing needs.',
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,108 @@
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { InitialValuesType } from './types';
const ffmpeg = new FFmpeg();
export async function trimAudio(
input: File,
options: InitialValuesType
): Promise<File> {
if (!ffmpeg.loaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
}
const inputName = 'input.mp3';
await ffmpeg.writeFile(inputName, await fetchFile(input));
const { startTime, endTime, outputFormat } = options;
const outputName = `output.${outputFormat}`;
// Build FFmpeg arguments for trimming
let args: string[] = [
'-i',
inputName,
'-ss',
startTime, // Start time
'-to',
endTime, // End time
'-c',
'copy' // Copy without re-encoding for speed
];
// Add format-specific arguments
if (outputFormat === 'mp3') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-ar',
'44100',
'-ac',
'2',
'-b:a',
'192k',
'-f',
'mp3',
outputName
];
} else if (outputFormat === 'aac') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-c:a',
'aac',
'-b:a',
'192k',
'-f',
'adts',
outputName
];
} else if (outputFormat === 'wav') {
args = [
'-i',
inputName,
'-ss',
startTime,
'-to',
endTime,
'-acodec',
'pcm_s16le',
'-ar',
'44100',
'-ac',
'2',
'-f',
'wav',
outputName
];
}
await ffmpeg.exec(args);
const trimmedAudio = await ffmpeg.readFile(outputName);
let mimeType = 'audio/mp3';
if (outputFormat === 'aac') mimeType = 'audio/aac';
if (outputFormat === 'wav') mimeType = 'audio/wav';
return new File(
[
new Blob([trimmedAudio], {
type: mimeType
})
],
`${input.name.replace(/\.[^/.]+$/, '')}_trimmed.${outputFormat}`,
{ type: mimeType }
);
}

View file

@ -0,0 +1,58 @@
import { expect, describe, it, vi } from 'vitest';
// Mock FFmpeg since it doesn't support Node.js
vi.mock('@ffmpeg/ffmpeg', () => ({
FFmpeg: vi.fn().mockImplementation(() => ({
loaded: false,
load: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
exec: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])),
deleteFile: vi.fn().mockResolvedValue(undefined)
}))
}));
vi.mock('@ffmpeg/util', () => ({
fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5]))
}));
import { trimAudio } from './service';
describe('trimAudio', () => {
it('should trim audio file with valid time parameters', async () => {
// Create a mock audio file
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.mp3', {
type: 'audio/mp3'
});
const options = {
startTime: '00:00:10',
endTime: '00:00:20',
outputFormat: 'mp3' as const
};
const result = await trimAudio(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result.name).toContain('_trimmed.mp3');
expect(result.type).toBe('audio/mp3');
});
it('should handle different output formats', async () => {
const mockAudioData = new Uint8Array([0, 1, 2, 3, 4, 5]);
const mockFile = new File([mockAudioData], 'test.wav', {
type: 'audio/wav'
});
const options = {
startTime: '00:00:00',
endTime: '00:00:30',
outputFormat: 'wav' as const
};
const result = await trimAudio(mockFile, options);
expect(result).toBeInstanceOf(File);
expect(result.name).toContain('_trimmed.wav');
expect(result.type).toBe('audio/wav');
});
});

View file

@ -0,0 +1,5 @@
export type InitialValuesType = {
startTime: string;
endTime: string;
outputFormat: 'mp3' | 'aac' | 'wav';
};

View file

@ -0,0 +1,164 @@
import { Box, Slider, Typography } from '@mui/material';
import ToolImageInput from 'components/input/ToolImageInput';
import ColorSelector from 'components/options/ColorSelector';
import ToolFileResult from 'components/result/ToolFileResult';
import Color from 'color';
import React, { useState } from 'react';
import * as Yup from 'yup';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
const initialValues = {
quality: 85,
backgroundColor: '#ffffff'
};
const validationSchema = Yup.object({
quality: Yup.number().min(1).max(100).required('Quality is required'),
backgroundColor: Yup.string().required('Background color is required')
});
export default function ConvertToJpg({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const compute = async (
optionsValues: typeof initialValues,
input: any
): Promise<void> => {
if (!input) return;
const processImage = async (
file: File,
quality: number,
backgroundColor: string
) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx == null) return;
const img = new Image();
img.src = URL.createObjectURL(file);
try {
await img.decode();
canvas.width = img.width;
canvas.height = img.height;
// Fill background with selected color (important for transparency)
let bgColor: [number, number, number];
try {
//@ts-ignore
bgColor = Color(backgroundColor).rgb().array();
} catch (err) {
bgColor = [255, 255, 255]; // Default to white
}
ctx.fillStyle = `rgb(${bgColor[0]}, ${bgColor[1]}, ${bgColor[2]})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw the image on top
ctx.drawImage(img, 0, 0);
// Convert to JPG with specified quality
canvas.toBlob(
(blob) => {
if (blob) {
const fileName = file.name.replace(/\.[^/.]+$/, '') + '.jpg';
const newFile = new File([blob], fileName, {
type: 'image/jpeg'
});
setResult(newFile);
}
},
'image/jpeg',
quality / 100
);
} catch (error) {
console.error('Error processing image:', error);
} finally {
URL.revokeObjectURL(img.src);
}
};
processImage(input, optionsValues.quality, optionsValues.backgroundColor);
};
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
accept={[
'image/png',
'image/gif',
'image/tiff',
'image/tif',
'image/webp',
'image/svg+xml',
'image/heic',
'image/heif',
'image/raw',
'image/x-adobe-dng',
'image/x-canon-cr2',
'image/x-canon-crw',
'image/x-nikon-nef',
'image/x-sony-arw',
'image/vnd.adobe.photoshop'
]}
title={'Input Image'}
/>
}
resultComponent={
<ToolFileResult title={'Output JPG'} value={result} extension={'jpg'} />
}
initialValues={initialValues}
validationSchema={validationSchema}
getGroups={({ values, updateField }) => [
{
title: 'JPG Quality Settings',
component: (
<Box>
<Box mb={3}>
<Typography variant="body2" color="text.secondary" gutterBottom>
JPG Quality: {values.quality}%
</Typography>
<Slider
value={values.quality}
onChange={(_, value) =>
updateField(
'quality',
Array.isArray(value) ? value[0] : value
)
}
min={1}
max={100}
step={1}
valueLabelDisplay="auto"
valueLabelFormat={(value) => `${value}%`}
sx={{ mt: 1 }}
/>
<Typography variant="caption" color="text.secondary">
Higher values = better quality, larger file size
</Typography>
</Box>
<ColorSelector
value={values.backgroundColor}
onColorChange={(val) => updateField('backgroundColor', val)}
description={'Background color (for transparent images)'}
inputProps={{ 'data-testid': 'background-color-input' }}
/>
</Box>
)
}
]}
compute={compute}
setInput={setInput}
/>
);
}

View file

@ -0,0 +1,27 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Convert Images to JPG',
path: 'convert-to-jpg',
icon: 'ph:file-jpg-thin',
description:
'Convert various image formats (PNG, GIF, TIF, PSD, SVG, WEBP, HEIC, RAW) to JPG with customizable quality and background color settings.',
shortDescription: 'Convert images to JPG with quality control',
keywords: [
'convert',
'jpg',
'jpeg',
'png',
'gif',
'tiff',
'webp',
'heic',
'raw',
'psd',
'svg',
'quality',
'compression'
],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,123 @@
import React, { useCallback, useState } from 'react';
import { Box } from '@mui/material';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
// Import the image editor with proper typing
import FilerobotImageEditor, {
FilerobotImageEditorConfig
} from 'react-filerobot-image-editor';
export default function ImageEditor({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [imageUrl, setImageUrl] = useState<string | null>(null);
// Handle file input change
const handleInputChange = useCallback((file: File | null) => {
setInput(file);
if (file) {
// Create object URL for the image editor
const url = URL.createObjectURL(file);
setImageUrl(url);
setIsEditorOpen(true);
} else {
setImageUrl(null);
}
}, []);
const onCloseEditor = (reason: string) => {
setIsEditorOpen(false);
setImageUrl(null);
};
// Handle save from image editor
const handleSave: FilerobotImageEditorConfig['onSave'] = (
editedImageObject,
designState
) => {
if (editedImageObject && editedImageObject.imageBase64) {
// Convert base64 to blob
const base64Data = editedImageObject.imageBase64.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: editedImageObject.mimeType });
const editedFile = new File(
[blob],
editedImageObject.fullName ?? 'edited.png',
{
type: editedImageObject.mimeType
}
);
// Create a temporary download link
const url = URL.createObjectURL(editedFile);
const a = document.createElement('a');
a.href = url;
a.download = editedFile.name; // This will be the name of the downloaded file
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Release the blob URL
URL.revokeObjectURL(url);
}
};
const getDefaultImageName = () => {
if (!input) return;
const originalName = input?.name || 'edited-image';
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '');
const editedFileName = `${nameWithoutExt}-edited`;
return editedFileName;
};
return (
<ToolContent
title={title}
initialValues={{}}
getGroups={null}
input={input}
inputComponent={
isEditorOpen ? (
imageUrl && (
<Box style={{ width: '100%', height: '70vh' }}>
<FilerobotImageEditor
source={imageUrl}
onSave={handleSave}
onClose={onCloseEditor}
annotationsCommon={{
fill: 'blue'
}}
defaultSavedImageName={getDefaultImageName()}
Rotate={{ angle: 90, componentType: 'slider' }}
savingPixelRatio={1}
previewPixelRatio={1}
/>
</Box>
)
) : (
<ToolImageInput
value={input}
onChange={handleInputChange}
accept={['image/*']}
title="Upload Image to Edit"
/>
)
}
toolInfo={{
title: 'Image Editor',
description:
'A powerful image editing tool that provides professional-grade features including cropping, rotating, color adjustments, text annotations, drawing tools, and watermarking. Edit your images directly in your browser without the need for external software.'
}}
compute={() => {}}
/>
);
}

View file

@ -0,0 +1,28 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Image Editor',
path: 'editor',
icon: 'mdi:image-edit',
description:
'Advanced image editor with tools for cropping, rotating, annotating, adjusting colors, and adding watermarks. Edit your images with professional-grade tools directly in your browser.',
shortDescription: 'Edit images with advanced tools and features',
keywords: [
'image',
'editor',
'edit',
'crop',
'rotate',
'annotate',
'adjust',
'watermark',
'text',
'drawing',
'filters',
'brightness',
'contrast',
'saturation'
],
component: lazy(() => import('./index'))
});

View file

@ -8,7 +8,10 @@ import { tool as createTransparent } from './create-transparent/meta';
import { tool as imageToText } from './image-to-text/meta';
import { tool as qrCodeGenerator } from './qr-code/meta';
import { tool as rotateImage } from './rotate/meta';
import { tool as convertToJpg } from './convert-to-jpg/meta';
import { tool as imageEditor } from './editor/meta';
export const imageGenericTools = [
imageEditor,
resizeImage,
compressImage,
removeBackground,
@ -18,5 +21,6 @@ export const imageGenericTools = [
createTransparent,
imageToText,
qrCodeGenerator,
rotateImage
rotateImage,
convertToJpg
];

View file

@ -240,6 +240,7 @@ export default async function makeTool(
description: calcData.longDescription
}}
verticalGroups
// @ts-ignore
getGroups={({ values, updateField }) => [
...(calcData.presets?.length
? [

View file

@ -0,0 +1,29 @@
import React from 'react';
import { Box } from '@mui/material';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { EmbedPDF } from '@simplepdf/react-embed-pdf';
export default function PdfEditor({ title }: ToolComponentProps) {
return (
<ToolContent
title={title}
initialValues={{}}
getGroups={null}
input={null}
inputComponent={
<Box sx={{ width: '100%', height: '80vh' }}>
<EmbedPDF mode="inline" style={{ width: '100%', height: '100%' }} />
</Box>
}
toolInfo={{
title: 'PDF Editor',
description:
'Edit, annotate, highlight, fill forms, and export your PDFs entirely in the browser. Add text, drawings, signatures, and more to your PDF documents with this powerful online editor.'
}}
compute={() => {
/* no background compute required */
}}
/>
);
}

View file

@ -0,0 +1,28 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('pdf', {
name: 'PDF Editor',
path: 'editor',
icon: 'mdi:file-document-edit',
description:
'Advanced PDF editor with annotation, form-fill, highlight, and export capabilities. Edit your PDFs directly in the browser with professional-grade tools including text insertion, drawing, highlighting, signing and form filling.',
shortDescription:
'Edit PDFs with advanced annotation, signing and editing tools',
keywords: [
'pdf',
'editor',
'edit',
'annotate',
'highlight',
'form',
'fill',
'text',
'drawing',
'signature',
'export',
'annotation',
'markup'
],
component: lazy(() => import('./index'))
});

View file

@ -6,8 +6,10 @@ import { DefinedTool } from '@tools/defineTool';
import { tool as compressPdfTool } from './compress-pdf/meta';
import { tool as protectPdfTool } from './protect-pdf/meta';
import { meta as pdfToEpub } from './pdf-to-epub/meta';
import { tool as pdfEditor } from './editor/meta';
export const pdfTools: DefinedTool[] = [
pdfEditor,
splitPdfMeta,
pdfRotatePdf,
compressPdfTool,

View file

@ -0,0 +1,158 @@
import { Box } from '@mui/material';
import { useState } from 'react';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { censorText } from './service';
import ToolTextInput from '@components/input/ToolTextInput';
import { InitialValuesType } from './types';
import ToolContent from '@components/ToolContent';
import { CardExampleType } from '@components/examples/ToolExamples';
import { ToolComponentProps } from '@tools/defineTool';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
const initialValues: InitialValuesType = {
wordsToCensor: '',
censoredBySymbol: true,
censorSymbol: '█',
eachLetter: true,
censorWord: 'CENSORED'
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Censor a Word in a Quote',
description: `In this example, we hide the unpleasant word "idiot" from Jim Rohn's quote. We specify this word in the words-to-censor option and mask it with a neat smiling face character "☺".`,
sampleText:
'Motivation alone is not enough. If you have an idiot and you motivate him, now you have a motivated idiot. Jim Rohn',
sampleResult:
'Motivation alone is not enough. If you have an ☺ and you motivate him, now you have a motivated ☺. Jim Rohn',
sampleOptions: {
...initialValues,
wordsToCensor: 'idiot',
censorSymbol: '☺',
eachLetter: false
}
},
{
title: 'Censor an Excerpt',
description: `In this example, we censor multiple words from an excerpt from the novel "The Guns of Avalon" by Roger Zelazny. To do this, we write out all unnecessary words in the multi-line text option and select the "Use a Symbol to Censor" censoring mode. We activate the "Mask Each Letter" option so that in place of each word exactly as many block characters "█" appeared as there are letters in that word.`,
sampleText:
'“In the mirrors of the many judgments, my hands are the color of blood. I sometimes fancy myself an evil which exists to oppose other evils; and on that great Day of which the prophets speak but in which they do not truly believe, on the day the world is utterly cleansed of evil, then I too will go down into darkness, swallowing curses. Until then, I will not wash my hands nor let them hang useless.” ― Roger Zelazny, The Guns of Avalon',
sampleResult:
'“In the mirrors of the many judgments, my hands are the color of █████. I sometimes fancy myself an ████ which exists to oppose other █████; and on that great Day of which the prophets speak but in which they do not truly believe, on the day the world is utterly cleansed of ████, then I too will go down into ████████, swallowing ██████. Until then, I will not wash my hands nor let them hang useless.” ― Roger Zelazny, The Guns of Avalon',
sampleOptions: {
...initialValues,
wordsToCensor: 'blood\nevil\ndarkness\ncurses',
eachLetter: true
}
},
{
title: "Censor Agent's Name",
description: `In this example, we hide the name of an undercover FBI agent. We replace two words at once (first name and last name) with the code name "Agent 007"`,
sampleText:
'My name is John and I am an undercover FBI agent. I usually write my name in lowercase as "john" because I find uppercase letters scary. Unfortunately, in documents, my name is properly capitalized as John and it makes me upset.',
sampleResult:
'My name is Agent 007 and I am an undercover FBI agent. I usually write my name in lowercase as "Agent 007" because I find uppercase letters scary. Unfortunately, in documents, my name is properly capitalized as Agent 007 and it makes me upset.',
sampleOptions: {
...initialValues,
censoredBySymbol: false,
wordsToCensor: 'john',
censorWord: 'Agent 007'
}
}
];
export default function CensorText({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
function compute(initialValues: InitialValuesType, input: string) {
setResult(censorText(input, initialValues));
}
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Words to Censor',
component: (
<Box>
<TextFieldWithDesc
multiline
rows={3}
value={values.wordsToCensor}
onOwnChange={(val) => updateField('wordsToCensor', val)}
description={`Specify all unwanted words that
you want to hide in text (separated by a new line)`}
/>
</Box>
)
},
{
title: 'Censor Mode',
component: (
<Box>
<SelectWithDesc
selected={values.censoredBySymbol}
options={[
{ label: 'Censor by Symbol', value: true },
{ label: 'Censor by Word', value: false }
]}
onChange={(value) => updateField('censoredBySymbol', value)}
description={'Select the censoring mode.'}
/>
{values.censoredBySymbol && (
<TextFieldWithDesc
value={values.censorSymbol}
onOwnChange={(val) => updateField('censorSymbol', val)}
description={`A symbol, character, or pattern to use for censoring.`}
/>
)}
{values.censoredBySymbol && (
<CheckboxWithDesc
checked={values.eachLetter}
onChange={(value) => updateField('eachLetter', value)}
title="Mask each letter"
description="Put a masking symbol in place of each letter of the censored word."
/>
)}
{!values.censoredBySymbol && (
<TextFieldWithDesc
value={values.censorWord}
onOwnChange={(val) => updateField('censorWord', val)}
description={`Replace all censored words with this word.`}
/>
)}
</Box>
)
}
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
setInput={setInput}
inputComponent={
<ToolTextInput title={'Input text'} value={input} onChange={setInput} />
}
resultComponent={
<ToolTextResult title={'Censored text'} value={result} />
}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
exampleCards={exampleCards}
/>
);
}

View file

@ -0,0 +1,16 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('string', {
name: 'Text Censor',
path: 'censor',
shortDescription:
'Quickly mask bad words or replace them with alternative words.',
icon: 'hugeicons:text-footnote',
description:
"utility for censoring words in text. Load your text in the input form on the left, specify all the bad words in the options, and you'll instantly get censored text in the output area.",
longDescription:
'With this online tool, you can censor certain words in any text. You can specify a list of unwanted words (such as swear words or secret words) and the program will replace them with alternative words and create a safe-to-read text. The words can be specified in a multi-line text field in the options by entering one word per line.',
keywords: ['text', 'censor', 'words', 'characters'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,49 @@
import { InitialValuesType } from './types';
export function censorText(input: string, options: InitialValuesType): string {
if (!input) return '';
if (!options.wordsToCensor) return input;
if (options.censoredBySymbol && !isSymbol(options.censorSymbol)) {
throw new Error('Enter a valid censor symbol (non-alphanumeric or emoji)');
}
const wordsToCensor = options.wordsToCensor
.split('\n')
.map((word) => word.trim())
.filter((word) => word.length > 0);
let censoredText = input;
for (const word of wordsToCensor) {
const escapedWord = escapeRegex(word);
const pattern = new RegExp(`\\b${escapedWord}\\b`, 'giu');
const replacement = options.censoredBySymbol
? options.eachLetter
? options.censorSymbol.repeat(word.length)
: options.censorSymbol
: options.censorWord;
censoredText = censoredText.replace(pattern, replacement);
}
return censoredText;
}
/**
* Escapes RegExp special characters in a string
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Determines if a string is a valid symbol or emoji (multi-codepoint supported).
*/
function isSymbol(input: string): boolean {
return (
/^[^\p{L}\p{N}]+$/u.test(input) || // Not a letter or number
/\p{Extended_Pictographic}/u.test(input) // Emoji or pictographic symbol
);
}

View file

@ -0,0 +1,7 @@
export type InitialValuesType = {
wordsToCensor: string;
censoredBySymbol: boolean;
censorSymbol: string;
eachLetter: boolean;
censorWord: string;
};

View file

@ -16,6 +16,7 @@ import { tool as stringRepeat } from './repeat/meta';
import { tool as stringTruncate } from './truncate/meta';
import { tool as stringBase64 } from './base64/meta';
import { tool as stringStatistic } from './statistic/meta';
import { tool as stringCensor } from './censor/meta';
export const stringTools = [
stringSplit,
@ -35,5 +36,6 @@ export const stringTools = [
stringRotate,
stringRot13,
stringBase64,
stringStatistic
stringStatistic,
stringCensor
];

View file

@ -0,0 +1,82 @@
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import { checkLeapYear } from './service';
const initialValues = {};
type InitialValuesType = typeof initialValues;
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Find Birthdays on February 29',
description:
"One of our friends was born on a leap year on February 29th and as a consequence, she has a birthday only once every 4 years. As we can never really remember when her birthday is, we are using our program to create a reminder list of the upcoming leap years. To create a list of her next birthdays, we load a sequence of years from 2025 to 2040 into the input and get the status of each year. If the program says that it's a leap year, then we know that we'll be invited to a birthday party on February 29th.",
sampleText: `2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040`,
sampleResult: `2025 is not a leap year.
2026 is not a leap year.
2027 is not a leap year.
2028 is a leap year.
2029 is not a leap year.
2030 is not a leap year.
2031 is not a leap year.
2032 is a leap year.
2033 is not a leap year.
2034 is not a leap year.
2035 is not a leap year.
2036 is a leap year.
2037 is not a leap year.
2038 is not a leap year.
2039 is not a leap year.
2040 is a leap year.`,
sampleOptions: {}
}
];
export default function ConvertDaysToHours({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (optionsValues: typeof initialValues, input: string) => {
setResult(checkLeapYear(input));
};
const getGroups: GetGroupsType<InitialValuesType> | null = null;
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,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('time', {
path: 'check-leap-years',
name: 'Check Leap Years',
icon: 'arcticons:calendar-simple-29',
description:
' You can check if a given calendar year is a leap year. You can enter one or many different years into the input field with one date per line and get the answer to the test question of whether the given year is a leap year.',
shortDescription: 'Convert days to hours easily.',
keywords: ['check', 'leap', 'years'],
longDescription: `This is a quick online utility for testing if the given year is a leap year. Just as a reminder, a leap year has 366 days, which is one more day than a common year. This extra day is added to the month of February and it falls on February 29th. There's a simple mathematical formula for calculating if the given year is a leap year. Leap years are those years that are divisible by 4 but not divisible by 100, as well as years that are divisible by 100 and 400 simultaneously. Our algorithm checks each input year using this formula and outputs the year's status. For example, if you enter the value "2025" as input, the program will display "2025 is not a leap year.", and for the value "2028", the status will be "2028 is a leap year.". You can also enter multiple years as the input in a column and get a matching column of statuses as the output.`,
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,25 @@
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
export function checkLeapYear(input: string): string {
if (!input) return '';
const years = input
.split('\n')
.map((year) => year.trim())
.filter((year) => year !== '');
const results = years.map((yearStr) => {
if (!/^\d{1,4}$/.test(yearStr)) {
return `${yearStr}: Invalid year`;
}
const year = Number(yearStr);
return `${year} ${
isLeapYear(year) ? 'is a leap year.' : 'is not a leap year.'
}`;
});
return results.join('\n');
}

View file

@ -6,6 +6,7 @@ import { tool as hoursToDays } from './convert-hours-to-days/meta';
import { tool as convertSecondsToTime } from './convert-seconds-to-time/meta';
import { tool as convertTimetoSeconds } from './convert-time-to-seconds/meta';
import { tool as truncateClockTime } from './truncate-clock-time/meta';
import { tool as checkLeapYear } from './check-leap-years/meta';
export const timeTools = [
daysDoHours,
@ -15,5 +16,6 @@ export const timeTools = [
truncateClockTime,
timeBetweenDates,
timeEpochConverter,
timeCrontabGuru
timeCrontabGuru,
checkLeapYear
];

View file

@ -0,0 +1,4 @@
import { tool as xmlXmlValidator } from './xml-validator/meta';
import { tool as xmlXmlBeautifier } from './xml-beautifier/meta';
export const xmlTools = [xmlXmlBeautifier, xmlXmlValidator];

View file

@ -0,0 +1,54 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import { beautifyXml } from './service';
import { InitialValuesType } from './types';
const initialValues: InitialValuesType = {};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Beautify XML',
description: 'Beautify a compact XML string for readability.',
sampleText: '<root><item>1</item><item>2</item></root>',
sampleResult: `<root>\n <item>1</item>\n <item>2</item>\n</root>`,
sampleOptions: {}
}
];
export default function XmlBeautifier({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (_values: InitialValuesType, input: string) => {
setResult(beautifyXml(input, {}));
};
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolTextInput
value={input}
onChange={setInput}
placeholder="Paste or import XML here..."
/>
}
resultComponent={<ToolTextResult value={result} extension="xml" />}
initialValues={initialValues}
exampleCards={exampleCards}
getGroups={null}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View file

@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('xml', {
name: 'XML Beautifier',
path: 'xml-beautifier',
icon: 'mdi:format-align-left',
description:
'Beautify and reformat XML for improved readability and structure.',
shortDescription: 'Beautify XML for readability.',
keywords: ['xml', 'beautify', 'format', 'pretty', 'indent'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,23 @@
import { InitialValuesType } from './types';
import { XMLParser, XMLBuilder, XMLValidator } from 'fast-xml-parser';
export function beautifyXml(
input: string,
_options: InitialValuesType
): string {
const valid = XMLValidator.validate(input);
if (valid !== true) {
if (typeof valid === 'object' && valid.err) {
return `Invalid XML: ${valid.err.msg} (line ${valid.err.line}, col ${valid.err.col})`;
}
return 'Invalid XML';
}
try {
const parser = new XMLParser();
const obj = parser.parse(input);
const builder = new XMLBuilder({ format: true, indentBy: ' ' });
return builder.build(obj);
} catch (e: any) {
return `Invalid XML: ${e.message}`;
}
}

View file

@ -0,0 +1,3 @@
export type InitialValuesType = {
// splitSeparator: string;
};

View file

@ -0,0 +1,18 @@
import { expect, describe, it } from 'vitest';
import { beautifyXml } from './service';
describe('xml-beautifier', () => {
it('beautifies valid XML', () => {
const input = '<root><a>1</a><b>2</b></root>';
const result = beautifyXml(input, {});
expect(result).toContain('<root>');
expect(result).toContain(' <a>1</a>');
expect(result).toContain(' <b>2</b>');
});
it('returns error for invalid XML', () => {
const input = '<root><a>1</b></root>';
const result = beautifyXml(input, {});
expect(result).toMatch(/Invalid XML/i);
});
});

View file

@ -0,0 +1,61 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import { validateXml } from './service';
import { InitialValuesType } from './types';
const initialValues: InitialValuesType = {};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Validate XML',
description: 'Check if an XML string is well-formed.',
sampleText: '<root><item>1</item><item>2</item></root>',
sampleResult: 'Valid XML',
sampleOptions: {}
},
{
title: 'Invalid XML',
description: 'Example of malformed XML.',
sampleText: '<root><item>1</item><item>2</root>',
sampleResult: 'Invalid XML: ...',
sampleOptions: {}
}
];
export default function XmlValidator({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (_values: InitialValuesType, input: string) => {
setResult(validateXml(input, {}));
};
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolTextInput
value={input}
onChange={setInput}
placeholder="Paste or import XML here..."
/>
}
resultComponent={<ToolTextResult value={result} extension="txt" />}
initialValues={initialValues}
exampleCards={exampleCards}
getGroups={null}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View file

@ -0,0 +1,13 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('xml', {
name: 'XML Validator',
path: 'xml-validator',
icon: 'mdi:check-decagram',
description:
'Validate XML files or strings to ensure they are well-formed and error-free.',
shortDescription: 'Validate XML for errors.',
keywords: ['xml', 'validate', 'check', 'syntax', 'error'],
component: lazy(() => import('./index'))
});

View file

@ -0,0 +1,16 @@
import { InitialValuesType } from './types';
import { XMLValidator } from 'fast-xml-parser';
export function validateXml(
input: string,
_options: InitialValuesType
): string {
const result = XMLValidator.validate(input);
if (result === true) {
return 'Valid XML';
} else if (typeof result === 'object' && result.err) {
return `Invalid XML: ${result.err.msg} (line ${result.err.line}, col ${result.err.col})`;
} else {
return 'Invalid XML: Unknown error';
}
}

View file

@ -0,0 +1,3 @@
export type InitialValuesType = {
// splitSeparator: string;
};

View file

@ -0,0 +1,16 @@
import { expect, describe, it } from 'vitest';
import { validateXml } from './service';
describe('xml-validator', () => {
it('returns Valid XML for well-formed XML', () => {
const input = '<root><a>1</a><b>2</b></root>';
const result = validateXml(input, {});
expect(result).toBe('Valid XML');
});
it('returns error for invalid XML', () => {
const input = '<root><a>1</b></root>';
const result = validateXml(input, {});
expect(result).toMatch(/Invalid XML/i);
});
});

View file

@ -24,7 +24,9 @@ export type ToolCategory =
| 'time'
| 'csv'
| 'pdf'
| 'image-generic';
| 'image-generic'
| 'audio'
| 'xml';
export interface DefinedTool {
type: ToolCategory;

View file

@ -1,9 +1,10 @@
import { stringTools } from '../pages/tools/string';
import { imageTools } from '../pages/tools/image';
import { DefinedTool, ToolCategory } from './defineTool';
import { capitalizeFirstLetter } from '../utils/string';
import { capitalizeFirstLetter } from '@utils/string';
import { numberTools } from '../pages/tools/number';
import { videoTools } from '../pages/tools/video';
import { audioTools } from 'pages/tools/audio';
import { listTools } from '../pages/tools/list';
import { Entries } from 'type-fest';
import { jsonTools } from '../pages/tools/json';
@ -11,18 +12,22 @@ import { csvTools } from '../pages/tools/csv';
import { timeTools } from '../pages/tools/time';
import { IconifyIcon } from '@iconify/react';
import { pdfTools } from '../pages/tools/pdf';
import { xmlTools } from '../pages/tools/xml';
const toolCategoriesOrder: ToolCategory[] = [
'image-generic',
'string',
'json',
'pdf',
'string',
'video',
'time',
'audio',
'json',
'list',
'csv',
'number',
'png',
'time',
'xml',
'gif'
];
export const tools: DefinedTool[] = [
@ -34,7 +39,9 @@ export const tools: DefinedTool[] = [
...csvTools,
...videoTools,
...numberTools,
...timeTools
...timeTools,
...audioTools,
...xmlTools
];
const categoriesConfig: {
type: ToolCategory;
@ -115,6 +122,18 @@ const categoriesConfig: {
icon: 'material-symbols-light:image-outline-rounded',
value:
'Tools for working with pictures compress, resize, crop, convert to JPG, rotate, remove background and much more.'
},
{
type: 'audio',
icon: 'ic:twotone-audiotrack',
value:
'Tools for working with audio extract audio from video, adjusting audio speed, merging multiple audio files and much more.'
},
{
type: 'xml',
icon: 'mdi-light:xml',
value:
'Tools for working with XML data structures - viewer, beautifier, validator and much more'
}
];
// use for changelogs