Merge branch 'main' into chesterkxng

This commit is contained in:
Chesterkxng
2025-03-28 17:02:26 +00:00
17 changed files with 1020 additions and 115 deletions

220
.idea/workspace.xml generated
View File

@@ -4,13 +4,8 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: result file name"> <list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="refactor: time between dates">
<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/time-between-dates/meta.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/meta.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-json/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-json/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/csv/csv-to-xml/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/json/minify/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/json/minify/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/json/prettify/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/json/prettify/index.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/pages/tools/json/stringify/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/json/stringify/index.tsx" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -27,78 +22,85 @@
<option name="PUSH_AUTO_UPDATE" value="true" /> <option name="PUSH_AUTO_UPDATE" value="true" />
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="chesterkxng" /> <entry key="$PROJECT_DIR$" value="main" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="RESET_MODE" value="HARD" /> <option name="RESET_MODE" value="HARD" />
</component> </component>
<component name="GitHubPullRequestSearchHistory"><![CDATA[{ <component name="GitHubPullRequestSearchHistory">{
"history": [ &quot;history&quot;: [
{ {
"assignee": "iib0011" &quot;assignee&quot;: &quot;iib0011&quot;
}, },
{ {
"state": "OPEN" &quot;state&quot;: &quot;OPEN&quot;
} }
], ],
"lastFilter": { &quot;lastFilter&quot;: {
"state": "OPEN" &quot;state&quot;: &quot;OPEN&quot;
} }
}]]></component> }</component>
<component name="GitHubPullRequestState"><![CDATA[{ <component name="GitHubPullRequestState">{
"prStates": [ &quot;prStates&quot;: [
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts51PkS9", &quot;id&quot;: &quot;PR_kwDOMJIfts51PkS9&quot;,
"number": 22 &quot;number&quot;: 22
}, },
"lastSeen": 1741207144695 &quot;lastSeen&quot;: 1741207144695
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts6NiNYl", &quot;id&quot;: &quot;PR_kwDOMJIfts6NiNYl&quot;,
"number": 32 &quot;number&quot;: 32
}, },
"lastSeen": 1741209723869 &quot;lastSeen&quot;: 1741209723869
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts6Nheyd", &quot;id&quot;: &quot;PR_kwDOMJIfts6Nheyd&quot;,
"number": 31 &quot;number&quot;: 31
}, },
"lastSeen": 1741213371410 &quot;lastSeen&quot;: 1741213371410
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts6NmRBs", &quot;id&quot;: &quot;PR_kwDOMJIfts6NmRBs&quot;,
"number": 33 &quot;number&quot;: 33
}, },
"lastSeen": 1741282429036 &quot;lastSeen&quot;: 1741282429036
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts5zyFTs", &quot;id&quot;: &quot;PR_kwDOMJIfts5zyFTs&quot;,
"number": 15 &quot;number&quot;: 15
}, },
"lastSeen": 1741535540953 &quot;lastSeen&quot;: 1741535540953
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts6QQB3c", &quot;id&quot;: &quot;PR_kwDOMJIfts6QQB3c&quot;,
"number": 59 &quot;number&quot;: 59
}, },
"lastSeen": 1743018960900 &quot;lastSeen&quot;: 1743018960900
}, },
{ {
"id": { &quot;id&quot;: {
"id": "PR_kwDOMJIfts6QMPEg", &quot;id&quot;: &quot;PR_kwDOMJIfts6QMPEg&quot;,
"number": 58 &quot;number&quot;: 58
}, },
"lastSeen": 1743019452983 &quot;lastSeen&quot;: 1743019452983
},
{
&quot;id&quot;: {
&quot;id&quot;: &quot;PR_kwDOMJIfts6QZvRI&quot;,
&quot;number&quot;: 61
},
&quot;lastSeen&quot;: 1743103196866
} }
] ]
}]]></component> }</component>
<component name="GithubPullRequestsUISettings">{ <component name="GithubPullRequestsUISettings">{
&quot;selectedUrlAndAccountId&quot;: { &quot;selectedUrlAndAccountId&quot;: {
&quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;, &quot;url&quot;: &quot;https://github.com/iib0011/omni-tools.git&quot;,
@@ -150,6 +152,7 @@
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.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 (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run", "Vitest.replaceText function.executor": "Run",
"Vitest.timeBetweenDates.executor": "Run",
"git-widget-placeholder": "main", "git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true", "ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true", "kotlin-language-version-configured": "true",
@@ -201,17 +204,17 @@
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" /> <recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" />
</key> </key>
</component> </component>
<component name="RunManager" selected="npm.dev"> <component name="RunManager" selected="Vitest.calculateTimeBetweenDates">
<configuration name="Create transparent PNG.should make png color transparent" type="JavaScriptTestRunnerPlaywright" temporary="true" nameIsGenerated="true"> <configuration name="calculateTimeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" /> <node-interpreter value="project" />
<playwright-package value="$PROJECT_DIR$/node_modules/@playwright/test" /> <vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" /> <working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs /> <envs />
<scope-kind value="TEST" /> <scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/image/png/create-transparent/create-transparent.e2e.spec.ts" /> <test-file value="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts" />
<test-names> <test-names>
<test-name value="Create transparent PNG" /> <test-name value="calculateTimeBetweenDates" />
<test-name value="should make png color transparent" />
</test-names> </test-names>
<method v="2" /> <method v="2" />
</configuration> </configuration>
@@ -242,6 +245,19 @@
</test-names> </test-names>
<method v="2" /> <method v="2" />
</configuration> </configuration>
<configuration name="timeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
<node-interpreter value="project" />
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
<working-dir value="$PROJECT_DIR$" />
<vitest-options value="--run" />
<envs />
<scope-kind value="SUITE" />
<test-file value="$PROJECT_DIR$/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts" />
<test-names>
<test-name value="timeBetweenDates" />
</test-names>
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true"> <configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile"> <deployment type="dockerfile">
<settings /> <settings />
@@ -258,30 +274,20 @@
<envs /> <envs />
<method v="2" /> <method v="2" />
</configuration> </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> <list>
<item itemvalue="npm.test:e2e" />
<item itemvalue="npm.dev" /> <item itemvalue="npm.dev" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" /> <item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="Vitest.parsePageRanges" /> <item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" /> <item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
</list> </list>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="Vitest.calculateTimeBetweenDates" />
<item itemvalue="Vitest.timeBetweenDates" />
<item itemvalue="npm.dev" /> <item itemvalue="npm.dev" />
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" /> <item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
<item itemvalue="Vitest.parsePageRanges" /> <item itemvalue="Vitest.parsePageRanges" />
<item itemvalue="Playwright.Create transparent PNG.should make png color transparent" />
<item itemvalue="npm.test:e2e" />
</list> </list>
</recent_temporary> </recent_temporary>
</component> </component>
@@ -365,39 +371,9 @@
<workItem from="1741547560596" duration="1671000" /> <workItem from="1741547560596" duration="1671000" />
<workItem from="1741567442768" duration="14127000" /> <workItem from="1741567442768" duration="14127000" />
<workItem from="1741971589699" duration="371000" /> <workItem from="1741971589699" duration="371000" />
<workItem from="1743018497879" duration="2495000" /> <workItem from="1743018497879" duration="3895000" />
</task> <workItem from="1743047367993" duration="986000" />
<task id="LOCAL-00120" summary="fix: bg"> <workItem from="1743103182313" duration="4264000" />
<option name="closed" value="true" />
<created>1740503419102</created>
<option name="number" value="00120" />
<option name="presentableId" value="LOCAL-00120" />
<option name="project" value="LOCAL" />
<updated>1740503419102</updated>
</task>
<task id="LOCAL-00121" summary="fix: bg">
<option name="closed" value="true" />
<created>1740504051051</created>
<option name="number" value="00121" />
<option name="presentableId" value="LOCAL-00121" />
<option name="project" value="LOCAL" />
<updated>1740504051051</updated>
</task>
<task id="LOCAL-00122" summary="fix: bg">
<option name="closed" value="true" />
<created>1740504100676</created>
<option name="number" value="00122" />
<option name="presentableId" value="LOCAL-00122" />
<option name="project" value="LOCAL" />
<updated>1740504100676</updated>
</task>
<task id="LOCAL-00123" summary="fix: bg">
<option name="closed" value="true" />
<created>1740505390205</created>
<option name="number" value="00123" />
<option name="presentableId" value="LOCAL-00123" />
<option name="project" value="LOCAL" />
<updated>1740505390205</updated>
</task> </task>
<task id="LOCAL-00124" summary="docs: readme"> <task id="LOCAL-00124" summary="docs: readme">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -759,7 +735,39 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1743020690384</updated> <updated>1743020690384</updated>
</task> </task>
<option name="localTasksCounter" value="169" /> <task id="LOCAL-00169" summary="chore: text result extensions">
<option name="closed" value="true" />
<created>1743022260639</created>
<option name="number" value="00169" />
<option name="presentableId" value="LOCAL-00169" />
<option name="project" value="LOCAL" />
<updated>1743022260639</updated>
</task>
<task id="LOCAL-00170" summary="chore: show new tools in landing">
<option name="closed" value="true" />
<created>1743051792459</created>
<option name="number" value="00170" />
<option name="presentableId" value="LOCAL-00170" />
<option name="project" value="LOCAL" />
<updated>1743051792459</updated>
</task>
<task id="LOCAL-00171" summary="chore: zoom on hover">
<option name="closed" value="true" />
<created>1743052111988</created>
<option name="number" value="00171" />
<option name="presentableId" value="LOCAL-00171" />
<option name="project" value="LOCAL" />
<updated>1743052111988</updated>
</task>
<task id="LOCAL-00172" summary="refactor: time between dates">
<option name="closed" value="true" />
<created>1743106796406</created>
<option name="number" value="00172" />
<option name="presentableId" value="LOCAL-00172" />
<option name="project" value="LOCAL" />
<updated>1743106796406</updated>
</task>
<option name="localTasksCounter" value="173" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -806,10 +814,6 @@
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" /> <option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
<option name="CHECK_NEW_TODO" value="false" /> <option name="CHECK_NEW_TODO" value="false" />
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" /> <option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
<MESSAGE value="fix: tsc" />
<MESSAGE value="style: optimizations" />
<MESSAGE value="fix: replace text service" />
<MESSAGE value="chore: smooth scroll for use this tool and examles" />
<MESSAGE value="feat: minify json" /> <MESSAGE value="feat: minify json" />
<MESSAGE value="feat: stringify json" /> <MESSAGE value="feat: stringify json" />
<MESSAGE value="feat: arithmetic sequence" /> <MESSAGE value="feat: arithmetic sequence" />
@@ -831,7 +835,11 @@
<MESSAGE value="feat: split pdf" /> <MESSAGE value="feat: split pdf" />
<MESSAGE value="fix: typo" /> <MESSAGE value="fix: typo" />
<MESSAGE value="chore: result file name" /> <MESSAGE value="chore: result file name" />
<option name="LAST_COMMIT_MESSAGE" value="chore: result file name" /> <MESSAGE value="chore: text result extensions" />
<MESSAGE value="chore: show new tools in landing" />
<MESSAGE value="chore: zoom on hover" />
<MESSAGE value="refactor: time between dates" />
<option name="LAST_COMMIT_MESSAGE" value="refactor: time between dates" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

View File

@@ -2,7 +2,7 @@
<img src="src/assets/logo.png" width="300" /> <img src="src/assets/logo.png" width="300" />
<br /><br /> <br /><br />
<a href="https://github.com/iib0011/omni-tools/releases"> <a href="https://github.com/iib0011/omni-tools/releases">
<img src="https://img.shields.io/badge/version-0.1.0-blue?style=for-the-badge" /> <img src="https://img.shields.io/badge/version-0.2.0-blue?style=for-the-badge" />
</a> </a>
<a href="https://hub.docker.com/r/iib0011/omni-tools"> <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" /> <img src="https://img.shields.io/docker/pulls/iib0011/omni-tools?style=for-the-badge&logo=docker" />

View File

@@ -19,9 +19,9 @@ const exampleTools: { label: string; url: string }[] = [
{ label: 'Sort a list', url: '/list/sort' }, { label: 'Sort a list', url: '/list/sort' },
{ label: 'Compress PNG', url: '/png/compress-png' }, { label: 'Compress PNG', url: '/png/compress-png' },
{ label: 'Split a text', url: '/string/split' }, { label: 'Split a text', url: '/string/split' },
{ label: 'Calculate number sum', url: '/number/sum' }, { label: 'Split PDF', url: '/pdf/split-pdf' },
{ label: 'Shuffle a list', url: '/list/shuffle' }, { label: 'Trim video', url: '/video/trim' },
{ label: 'Change colors in image', url: '/png/change-colors-in-png' } { label: 'Calculate number sum', url: '/number/sum' }
]; ];
export default function Hero() { export default function Hero() {
const [inputValue, setInputValue] = useState<string>(''); const [inputValue, setInputValue] = useState<string>('');

View File

@@ -21,7 +21,11 @@ export default function ToolCard({
borderColor: '#5581b5', borderColor: '#5581b5',
color: '#fff', color: '#fff',
boxShadow: '6px 6px 12px #b8b9be, -6px -6px 12px #fff', boxShadow: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
cursor: 'pointer' cursor: 'pointer',
height: '100%',
'&:hover': {
transform: 'scale(1.05)'
}
}} }}
> >
<CardContent> <CardContent>

View File

@@ -1,4 +1,5 @@
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta'; import { meta as splitPdfMeta } from './split-pdf/meta';
import { DefinedTool } from '@tools/defineTool'; import { DefinedTool } from '@tools/defineTool';
export const pdfTools: DefinedTool[] = [splitPdfMeta]; export const pdfTools: DefinedTool[] = [splitPdfMeta, pdfRotatePdf];

View File

@@ -0,0 +1,242 @@
import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
import React, { useEffect, useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolPdfInput from '@components/input/ToolPdfInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import { PDFDocument } from 'pdf-lib';
import { InitialValuesType, RotationAngle } from './types';
import { parsePageRanges, rotatePdf } from './service';
import SimpleRadio from '@components/options/SimpleRadio';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
const initialValues: InitialValuesType = {
rotationAngle: 90,
applyToAllPages: true,
pageRanges: ''
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Rotate All Pages 90°',
description: 'Rotate all pages in the document 90 degrees clockwise',
sampleText: '',
sampleResult: '',
sampleOptions: {
rotationAngle: 90,
applyToAllPages: true,
pageRanges: ''
}
},
{
title: 'Rotate Specific Pages 180°',
description: 'Rotate only pages 1 and 3 by 180 degrees',
sampleText: '',
sampleResult: '',
sampleOptions: {
rotationAngle: 180,
applyToAllPages: false,
pageRanges: '1,3'
}
},
{
title: 'Rotate Page Range 270°',
description: 'Rotate pages 2 through 5 by 270 degrees',
sampleText: '',
sampleResult: '',
sampleOptions: {
rotationAngle: 270,
applyToAllPages: false,
pageRanges: '2-5'
}
}
];
export default function RotatePdf({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [totalPages, setTotalPages] = useState<number>(0);
const [pageRangePreview, setPageRangePreview] = useState<string>('');
// Get the total number of pages when a PDF is uploaded
useEffect(() => {
const getPdfInfo = async () => {
if (!input) {
setTotalPages(0);
return;
}
try {
const arrayBuffer = await input.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
setTotalPages(pdf.getPageCount());
} catch (error) {
console.error('Error getting PDF info:', error);
setTotalPages(0);
}
};
getPdfInfo();
}, [input]);
const onValuesChange = (values: InitialValuesType) => {
const { pageRanges, applyToAllPages } = values;
if (applyToAllPages) {
setPageRangePreview(
totalPages > 0 ? `All ${totalPages} pages will be rotated` : ''
);
return;
}
if (!totalPages || !pageRanges?.trim()) {
setPageRangePreview('');
return;
}
try {
const count = parsePageRanges(pageRanges, totalPages).length;
setPageRangePreview(
`${count} page${count !== 1 ? 's' : ''} will be rotated`
);
} catch (error) {
setPageRangePreview('');
}
};
const compute = async (values: InitialValuesType, input: File | null) => {
if (!input) return;
try {
setIsProcessing(true);
const rotatedPdf = await rotatePdf(input, values);
setResult(rotatedPdf);
} catch (error) {
throw new Error('Error rotating PDF: ' + error);
} finally {
setIsProcessing(false);
}
};
const angleOptions: { value: RotationAngle; label: string }[] = [
{ value: 90, label: '90° Clockwise' },
{ value: 180, label: '180° (Upside down)' },
{ value: 270, label: '270° (90° Counter-clockwise)' }
];
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={initialValues}
compute={compute}
exampleCards={exampleCards}
inputComponent={
<ToolPdfInput
value={input}
onChange={setInput}
accept={['application/pdf']}
title={'Input PDF'}
/>
}
resultComponent={
<ToolFileResult
title={'Rotated PDF'}
value={result}
extension={'pdf'}
loading={isProcessing}
loadingText={'Rotating pages'}
/>
}
getGroups={({ values, updateField }) => [
{
title: 'Rotation Settings',
component: (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Rotation Angle
</Typography>
{angleOptions.map((angleOption) => (
<SimpleRadio
key={angleOption.value}
title={angleOption.label}
checked={values.rotationAngle === angleOption.value}
onClick={() => {
updateField('rotationAngle', angleOption.value);
}}
/>
))}
<Box sx={{ mt: 2 }}>
<FormControlLabel
control={
<Switch
checked={values.applyToAllPages}
onChange={(e) => {
updateField('applyToAllPages', e.target.checked);
}}
/>
}
label="Apply to all pages"
/>
</Box>
{!values.applyToAllPages && (
<Box sx={{ mt: 2 }}>
{totalPages > 0 && (
<Typography variant="body2" sx={{ mb: 1 }}>
PDF has {totalPages} page{totalPages !== 1 ? 's' : ''}
</Typography>
)}
<TextFieldWithDesc
value={values.pageRanges}
onOwnChange={(val) => {
updateField('pageRanges', val);
}}
description={
'Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)'
}
placeholder={'e.g., 1,5-8'}
/>
{pageRangePreview && (
<Typography
variant="body2"
sx={{ mt: 1, color: 'primary.main' }}
>
{pageRangePreview}
</Typography>
)}
</Box>
)}
</Box>
)
}
]}
onValuesChange={onValuesChange}
toolInfo={{
title: 'How to Use the Rotate PDF Tool',
description: `This tool allows you to rotate pages in a PDF document. You can rotate all pages or specify individual pages to rotate.
Choose a rotation angle:
- 90° Clockwise
- 180° (Upside down)
- 270° (90° Counter-clockwise)
To rotate specific pages:
1. Uncheck "Apply to all pages"
2. Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)
Examples:
- "1,5,9" rotates pages 1, 5, and 9
- "1-5" rotates pages 1 through 5
- "1,3-5,8-10" rotates pages 1, 3, 4, 5, 8, 9, and 10
${longDescription}`
}}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('pdf', {
name: 'Rotate PDF',
path: 'rotate-pdf',
icon: 'carbon:rotate',
description: 'Rotate PDF pages by 90, 180, or 270 degrees',
shortDescription: 'Rotate pages in a PDF document',
keywords: ['pdf', 'rotate', 'rotation', 'document', 'pages', 'orientation'],
longDescription:
'Change the orientation of PDF pages by rotating them 90, 180, or 270 degrees. Useful for fixing incorrectly scanned documents or preparing PDFs for printing.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { parsePageRanges } from './service';
describe('rotate-pdf', () => {
describe('parsePageRanges', () => {
it('should return all pages when pageRanges is empty', () => {
const result = parsePageRanges('', 5);
expect(result).toEqual([1, 2, 3, 4, 5]);
});
it('should parse single page numbers', () => {
const result = parsePageRanges('1,3,5', 5);
expect(result).toEqual([1, 3, 5]);
});
it('should parse page ranges', () => {
const result = parsePageRanges('2-4', 5);
expect(result).toEqual([2, 3, 4]);
});
it('should parse mixed page numbers and ranges', () => {
const result = parsePageRanges('1,3-5', 5);
expect(result).toEqual([1, 3, 4, 5]);
});
it('should ignore invalid page numbers', () => {
const result = parsePageRanges('1,8,3', 5);
expect(result).toEqual([1, 3]);
});
it('should handle whitespace', () => {
const result = parsePageRanges(' 1, 3 - 5 ', 5);
expect(result).toEqual([1, 3, 4, 5]);
});
});
});

View File

@@ -0,0 +1,82 @@
import { degrees, PDFDocument } from 'pdf-lib';
import { InitialValuesType } from './types';
/**
* Parses a page range string and returns an array of page numbers
* @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7
* @param totalPages Total number of pages in the PDF
* @returns Array of page numbers to extract
*/
export function parsePageRanges(
pageRangeStr: string,
totalPages: number
): number[] {
if (!pageRangeStr.trim()) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pageNumbers = new Set<number>();
const ranges = pageRangeStr.split(',');
for (const range of ranges) {
const trimmedRange = range.trim();
if (trimmedRange.includes('-')) {
const [start, end] = trimmedRange.split('-').map(Number);
if (!isNaN(start) && !isNaN(end)) {
// Handle both forward and reversed ranges
const normalizedStart = Math.min(start, end);
const normalizedEnd = Math.max(start, end);
for (
let i = Math.max(1, normalizedStart);
i <= Math.min(totalPages, normalizedEnd);
i++
) {
pageNumbers.add(i);
}
}
} else {
const pageNum = parseInt(trimmedRange, 10);
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
pageNumbers.add(pageNum);
}
}
}
return [...pageNumbers].sort((a, b) => a - b);
}
/**
* Rotates pages in a PDF file
* @param pdfFile The input PDF file
* @param options Options including rotation angle and page selection
* @returns Promise resolving to a new PDF file with rotated pages
*/
export async function rotatePdf(
pdfFile: File,
options: InitialValuesType
): Promise<File> {
const { rotationAngle, applyToAllPages, pageRanges } = options;
const arrayBuffer = await pdfFile.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const totalPages = pdfDoc.getPageCount();
// Determine which pages to rotate
const pagesToRotate = applyToAllPages
? Array.from({ length: totalPages }, (_, i) => i + 1)
: parsePageRanges(pageRanges, totalPages);
// Apply rotation to selected pages
for (const pageNum of pagesToRotate) {
const page = pdfDoc.getPage(pageNum - 1);
page.setRotation(degrees(rotationAngle));
}
// Save the modified PDF
const modifiedPdfBytes = await pdfDoc.save();
const newFileName = pdfFile.name.replace('.pdf', '-rotated.pdf');
return new File([modifiedPdfBytes], newFileName, { type: 'application/pdf' });
}

View File

@@ -0,0 +1,7 @@
export type RotationAngle = 90 | 180 | 270;
export type InitialValuesType = {
rotationAngle: RotationAngle;
applyToAllPages: boolean;
pageRanges: string;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,12 +16,12 @@ export const tools: DefinedTool[] = [
...imageTools, ...imageTools,
...stringTools, ...stringTools,
...jsonTools, ...jsonTools,
...pdfTools,
...listTools, ...listTools,
...csvTools, ...csvTools,
...videoTools, ...videoTools,
...numberTools, ...numberTools,
...timeTools, ...timeTools
...pdfTools
]; ];
const categoriesConfig: { const categoriesConfig: {
type: ToolCategory; type: ToolCategory;
@@ -66,6 +66,12 @@ const categoriesConfig: {
value: value:
'Tools for working with JSON data structures prettify and minify JSON objects, flatten JSON arrays, stringify JSON values, analyze data, and much more' 'Tools for working with JSON data structures prettify and minify JSON objects, flatten JSON arrays, stringify JSON values, analyze data, and much more'
}, },
{
type: 'time',
icon: 'mdi:clock-time-five',
value:
'Tools for working with time and date calculate time differences, convert between time zones, format dates, generate date sequences, and much more.'
},
{ {
type: 'csv', type: 'csv',
icon: 'material-symbols-light:csv-outline', icon: 'material-symbols-light:csv-outline',
@@ -91,6 +97,11 @@ const categoriesConfig: {
'Tools for working with time and date draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.' 'Tools for working with time and date draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.'
} }
]; ];
// use for changelogs
// console.log(
// 'tools',
// tools.map(({ name, type }) => ({ type, name }))
// );
export const filterTools = ( export const filterTools = (
tools: DefinedTool[], tools: DefinedTool[],
query: string query: string