From ec5dbb50cce6858d83fb449e013b3eeb2d6c3486 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 11 Jul 2025 10:41:53 +0200 Subject: [PATCH 001/164] feat(docker): update the ci workflow to also push to ghcr.io --- .github/workflows/ci.yml | 16 +++++++++++++++- README.md | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01d7b95..c0a70fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,12 @@ on: pull_request: branches: - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + + jobs: test-and-build: runs-on: ubuntu-latest @@ -65,11 +71,19 @@ jobs: with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to the Github Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v4 with: - images: iib0011/omni-tools + images: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + iib0011/omni-tools tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/README.md b/README.md index f4db6d6..269f927 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ Stay tuned as we continue to expand and improve our collection! ## Self-host/Run +> [!TIP] +> You can use either the DockerHub Image `iib0011/omni-tools`
+> or use the GitHub Container Registry `ghcr.io/iib0011/omni-tools` + ### Docker ```bash From 8fc9081487d4bef20b41cb1262a4b1c91a38c723 Mon Sep 17 00:00:00 2001 From: Srivarshan-T Date: Sun, 20 Jul 2025 00:19:13 +0530 Subject: [PATCH 002/164] feat: add AAC to MP3 audio converter tool --- public/locales/en/audio.json | 6 ++ .../audio/AAC-MP3/AAC-MP3.service.test.ts | 44 ++++++++++++++ src/pages/tools/audio/AAC-MP3/index.tsx | 59 +++++++++++++++++++ src/pages/tools/audio/AAC-MP3/meta.ts | 15 +++++ src/pages/tools/audio/AAC-MP3/service.ts | 35 +++++++++++ src/pages/tools/audio/index.ts | 4 +- 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts create mode 100644 src/pages/tools/audio/AAC-MP3/index.tsx create mode 100644 src/pages/tools/audio/AAC-MP3/meta.ts create mode 100644 src/pages/tools/audio/AAC-MP3/service.ts diff --git a/public/locales/en/audio.json b/public/locales/en/audio.json index 5184efe..9a95626 100644 --- a/public/locales/en/audio.json +++ b/public/locales/en/audio.json @@ -57,5 +57,11 @@ "title": "What is {{title}}?" }, "trimmingAudio": "Trimming Audio" + }, + "AACMP3": { + "title": "AAC to MP3", + "description": "convert AAC audio files to MP3 format.Which is a common audio format used for music and other audio files.", + "shortDescription": "convert AAC audio files to MP3 format.", + "longDescription": "convert AAC audio files to MP3 format. MP3 is a widely used audio format that provides good sound quality and is compatible with most devices and platforms. This tool allows you to convert your AAC files to MP3 easily, making it convenient for playback on various devices." } } diff --git a/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts b/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts new file mode 100644 index 0000000..07ae026 --- /dev/null +++ b/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock FFmpeg and fetchFile +vi.mock('@ffmpeg/ffmpeg', () => ({ + FFmpeg: vi.fn().mockImplementation(() => ({ + load: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4, 5])), + unlink: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) +})); + +// Import service +import { AACtoMp3 } from './service'; + +describe('convertAACtoMP3', () => { + it('should return a new MP3 File when given a valid AAC file', async () => { + const mockAACData = new Uint8Array([0, 1, 2, 3, 4, 5]); + const mockFile = new File([mockAACData], 'sample.aac', { + type: 'audio/aac' + }); + + const result = await AACtoMp3(mockFile); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('sample.mp3'); + expect(result.type).toBe('audio/mpeg'); + }); + + it('should throw error if file type is not AAC', async () => { + const mockFile = new File(['dummy'], 'song.wav', { + type: 'audio/wav' + }); + + await expect(() => AACtoMp3(mockFile)).rejects.toThrowError( + 'Only .aac files are allowed.' // FIXED to match actual error + ); + }); +}); diff --git a/src/pages/tools/audio/AAC-MP3/index.tsx b/src/pages/tools/audio/AAC-MP3/index.tsx new file mode 100644 index 0000000..bab58cc --- /dev/null +++ b/src/pages/tools/audio/AAC-MP3/index.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolAudioInput from '@components/input/ToolAudioInput'; +import ToolFileResult from '@components/result/ToolFileResult'; + +import { AACtoMp3 } from './service'; + +export default function AACMP3({ title, longDescription }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + const compute = async ( + _optionsValues: {}, + input: File | null + ): Promise => { + if (!input) return; + + try { + if (!input.name.toLowerCase().endsWith('.aac')) { + setInput(null); + alert('please upload .aac files are allowed.'); + setResult(null); + + return; + } + setLoading(true); + const resultFile = await AACtoMp3(input); + setResult(resultFile); + } catch (error) { + console.error('Conversion failed:', error); + setResult(null); + } + setLoading(false); + }; + + return ( + + } + resultComponent={ + + } + initialValues={{}} + getGroups={null} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/audio/AAC-MP3/meta.ts b/src/pages/tools/audio/AAC-MP3/meta.ts new file mode 100644 index 0000000..5252bda --- /dev/null +++ b/src/pages/tools/audio/AAC-MP3/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('audio', { + i18n: { + name: 'audio:AACMP3.title', + description: 'audio:AACMP3.description', + shortDescription: 'audio:AACMP3.shortDescription', + longDescription: 'audio:AACMP3.longDescription' + }, + path: 'AAC-MP3', + icon: 'bi:filetype-mp3', + keywords: ['AAC', 'MP3', 'convert', 'audio', 'file conversion'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/audio/AAC-MP3/service.ts b/src/pages/tools/audio/AAC-MP3/service.ts new file mode 100644 index 0000000..f3d37d6 --- /dev/null +++ b/src/pages/tools/audio/AAC-MP3/service.ts @@ -0,0 +1,35 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; + +const ffmpeg = new FFmpeg(); +let isLoaded = false; + +export async function AACtoMp3(input: File): Promise { + if (!isLoaded) { + await ffmpeg.load(); + isLoaded = true; + } + + const inName = 'input.aac'; + const outName = 'output.mp3'; + + await ffmpeg.writeFile(inName, await fetchFile(input)); + + await ffmpeg.exec([ + '-i', + inName, + '-c:a', + 'libmp3lame', + '-b:a', + '192k', + outName + ]); + + const data = await ffmpeg.readFile(outName); + + const mp3 = new File([data], input.name.replace(/\.aac$/i, '.mp3'), { + type: 'audio/mpeg' + }); + + return mp3; +} diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts index 3596a1c..12341b4 100644 --- a/src/pages/tools/audio/index.ts +++ b/src/pages/tools/audio/index.ts @@ -1,3 +1,4 @@ +import { tool as audioAACMP3 } from './AAC-MP3/meta'; import { tool as audioMergeAudio } from './merge-audio/meta'; import { tool as audioTrim } from './trim/meta'; import { tool as audioChangeSpeed } from './change-speed/meta'; @@ -7,5 +8,6 @@ export const audioTools = [ audioExtractAudio, audioChangeSpeed, audioTrim, - audioMergeAudio + audioMergeAudio, + audioAACMP3 ]; From dffabc8134ab52238fb65d44a7757278f3161f4f Mon Sep 17 00:00:00 2001 From: Srivarshan-T Date: Fri, 25 Jul 2025 13:54:48 +0530 Subject: [PATCH 003/164] feat: implement audio converter tool with support for MP3, AAC, and WAV formats --- public/locales/en/converters.json | 8 ++ public/locales/en/translation.json | 12 ++ .../audio/AAC-MP3/AAC-MP3.service.test.ts | 44 ------- src/pages/tools/audio/AAC-MP3/index.tsx | 59 --------- src/pages/tools/audio/AAC-MP3/meta.ts | 15 --- src/pages/tools/audio/AAC-MP3/service.ts | 35 ------ src/pages/tools/audio/index.ts | 4 +- .../audio-converter.service.test.ts | 64 ++++++++++ .../converters/audio-converter/index.tsx | 112 ++++++++++++++++++ .../tools/converters/audio-converter/meta.ts | 15 +++ .../converters/audio-converter/service.ts | 100 ++++++++++++++++ src/pages/tools/converters/index.ts | 3 + src/tools/defineTool.tsx | 3 +- src/tools/index.ts | 13 +- 14 files changed, 328 insertions(+), 159 deletions(-) create mode 100644 public/locales/en/converters.json delete mode 100644 src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts delete mode 100644 src/pages/tools/audio/AAC-MP3/index.tsx delete mode 100644 src/pages/tools/audio/AAC-MP3/meta.ts delete mode 100644 src/pages/tools/audio/AAC-MP3/service.ts create mode 100644 src/pages/tools/converters/audio-converter/audio-converter.service.test.ts create mode 100644 src/pages/tools/converters/audio-converter/index.tsx create mode 100644 src/pages/tools/converters/audio-converter/meta.ts create mode 100644 src/pages/tools/converters/audio-converter/service.ts create mode 100644 src/pages/tools/converters/index.ts diff --git a/public/locales/en/converters.json b/public/locales/en/converters.json new file mode 100644 index 0000000..eb48c4d --- /dev/null +++ b/public/locales/en/converters.json @@ -0,0 +1,8 @@ +{ + "audioconverter": { + "title": "Audio Converter", + "description": "Convert audio files between different formats.", + "shortDescription": "Convert audio files to various formats.", + "longDescription": "This tool allows you to convert audio files from one format to another, supporting a wide range of audio formats for seamless conversion." + } +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 789e161..b6c69fb 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -17,11 +17,23 @@ "fileCopied": "File copied", "selectFileDescription": "Click here to select a {{type}} from your device, press Ctrl+V to use a {{type}} from your clipboard, or drag and drop a file from desktop" }, + "converters": { + "audioconverter": { + "title": "Audio Converter", + "description": "Convert audio files between different formats.", + "shortDescription": "Convert audio files to various formats.", + "longDescription": "This tool allows you to convert audio files from one format to another, supporting a wide range of audio formats for seamless conversion." + } + }, "categories": { "audio": { "description": "Tools for working with audio – extract audio from video, adjusting audio speed, merging multiple audio files and much more.", "title": "Audio Tools" }, + "converters": { + "description": "Tools for converting data between different formats – convert images, audio, video, text, and more.", + "title": "Converter Tools" + }, "csv": { "description": "Tools for working with CSV files - convert CSV to different formats, manipulate CSV data, validate CSV structure, and process CSV files efficiently.", "title": "CSV Tools" diff --git a/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts b/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts deleted file mode 100644 index 07ae026..0000000 --- a/src/pages/tools/audio/AAC-MP3/AAC-MP3.service.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; - -// Mock FFmpeg and fetchFile -vi.mock('@ffmpeg/ffmpeg', () => ({ - FFmpeg: vi.fn().mockImplementation(() => ({ - load: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - exec: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4, 5])), - unlink: vi.fn().mockResolvedValue(undefined) - })) -})); - -vi.mock('@ffmpeg/util', () => ({ - fetchFile: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4, 5])) -})); - -// Import service -import { AACtoMp3 } from './service'; - -describe('convertAACtoMP3', () => { - it('should return a new MP3 File when given a valid AAC file', async () => { - const mockAACData = new Uint8Array([0, 1, 2, 3, 4, 5]); - const mockFile = new File([mockAACData], 'sample.aac', { - type: 'audio/aac' - }); - - const result = await AACtoMp3(mockFile); - - expect(result).toBeInstanceOf(File); - expect(result.name).toBe('sample.mp3'); - expect(result.type).toBe('audio/mpeg'); - }); - - it('should throw error if file type is not AAC', async () => { - const mockFile = new File(['dummy'], 'song.wav', { - type: 'audio/wav' - }); - - await expect(() => AACtoMp3(mockFile)).rejects.toThrowError( - 'Only .aac files are allowed.' // FIXED to match actual error - ); - }); -}); diff --git a/src/pages/tools/audio/AAC-MP3/index.tsx b/src/pages/tools/audio/AAC-MP3/index.tsx deleted file mode 100644 index bab58cc..0000000 --- a/src/pages/tools/audio/AAC-MP3/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { useState } from 'react'; -import ToolContent from '@components/ToolContent'; -import { ToolComponentProps } from '@tools/defineTool'; -import ToolAudioInput from '@components/input/ToolAudioInput'; -import ToolFileResult from '@components/result/ToolFileResult'; - -import { AACtoMp3 } from './service'; - -export default function AACMP3({ title, longDescription }: ToolComponentProps) { - const [input, setInput] = useState(null); - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - - const compute = async ( - _optionsValues: {}, - input: File | null - ): Promise => { - if (!input) return; - - try { - if (!input.name.toLowerCase().endsWith('.aac')) { - setInput(null); - alert('please upload .aac files are allowed.'); - setResult(null); - - return; - } - setLoading(true); - const resultFile = await AACtoMp3(input); - setResult(resultFile); - } catch (error) { - console.error('Conversion failed:', error); - setResult(null); - } - setLoading(false); - }; - - return ( - - } - resultComponent={ - - } - initialValues={{}} - getGroups={null} - setInput={setInput} - compute={compute} - toolInfo={{ title: `What is a ${title}?`, description: longDescription }} - /> - ); -} diff --git a/src/pages/tools/audio/AAC-MP3/meta.ts b/src/pages/tools/audio/AAC-MP3/meta.ts deleted file mode 100644 index 5252bda..0000000 --- a/src/pages/tools/audio/AAC-MP3/meta.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineTool } from '@tools/defineTool'; -import { lazy } from 'react'; - -export const tool = defineTool('audio', { - i18n: { - name: 'audio:AACMP3.title', - description: 'audio:AACMP3.description', - shortDescription: 'audio:AACMP3.shortDescription', - longDescription: 'audio:AACMP3.longDescription' - }, - path: 'AAC-MP3', - icon: 'bi:filetype-mp3', - keywords: ['AAC', 'MP3', 'convert', 'audio', 'file conversion'], - component: lazy(() => import('./index')) -}); diff --git a/src/pages/tools/audio/AAC-MP3/service.ts b/src/pages/tools/audio/AAC-MP3/service.ts deleted file mode 100644 index f3d37d6..0000000 --- a/src/pages/tools/audio/AAC-MP3/service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FFmpeg } from '@ffmpeg/ffmpeg'; -import { fetchFile } from '@ffmpeg/util'; - -const ffmpeg = new FFmpeg(); -let isLoaded = false; - -export async function AACtoMp3(input: File): Promise { - if (!isLoaded) { - await ffmpeg.load(); - isLoaded = true; - } - - const inName = 'input.aac'; - const outName = 'output.mp3'; - - await ffmpeg.writeFile(inName, await fetchFile(input)); - - await ffmpeg.exec([ - '-i', - inName, - '-c:a', - 'libmp3lame', - '-b:a', - '192k', - outName - ]); - - const data = await ffmpeg.readFile(outName); - - const mp3 = new File([data], input.name.replace(/\.aac$/i, '.mp3'), { - type: 'audio/mpeg' - }); - - return mp3; -} diff --git a/src/pages/tools/audio/index.ts b/src/pages/tools/audio/index.ts index 12341b4..3596a1c 100644 --- a/src/pages/tools/audio/index.ts +++ b/src/pages/tools/audio/index.ts @@ -1,4 +1,3 @@ -import { tool as audioAACMP3 } from './AAC-MP3/meta'; import { tool as audioMergeAudio } from './merge-audio/meta'; import { tool as audioTrim } from './trim/meta'; import { tool as audioChangeSpeed } from './change-speed/meta'; @@ -8,6 +7,5 @@ export const audioTools = [ audioExtractAudio, audioChangeSpeed, audioTrim, - audioMergeAudio, - audioAACMP3 + audioMergeAudio ]; diff --git a/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts new file mode 100644 index 0000000..ae4ae6e --- /dev/null +++ b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts @@ -0,0 +1,64 @@ +import { expect, describe, it, vi, beforeEach } from 'vitest'; + +// Mock FFmpeg since it doesn't support Node.js in tests +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([10, 20, 30, 40, 50])), + deleteFile: vi.fn().mockResolvedValue(undefined) + })) +})); + +vi.mock('@ffmpeg/util', () => ({ + fetchFile: vi.fn().mockResolvedValue(new Uint8Array([10, 20, 30, 40, 50])) +})); + +import { convertAudio } from './service'; + +describe('convertAudio', () => { + let mockInputFile: File; + + beforeEach(() => { + const mockAudioData = new Uint8Array([1, 2, 3, 4, 5]); + mockInputFile = new File([mockAudioData], 'input.aac', { + type: 'audio/aac' + }); + }); + + it('should convert to MP3 format correctly', async () => { + const outputFormat = 'mp3' as const; + const result = await convertAudio(mockInputFile, outputFormat); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('input.mp3'); // base name + outputFormat extension + expect(result.type).toBe('audio/mpeg'); + }); + + it('should convert to AAC format correctly', async () => { + const outputFormat = 'aac' as const; + const result = await convertAudio(mockInputFile, outputFormat); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('input.aac'); + expect(result.type).toBe('audio/aac'); + }); + + it('should convert to WAV format correctly', async () => { + const outputFormat = 'wav' as const; + const result = await convertAudio(mockInputFile, outputFormat); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('input.wav'); + expect(result.type).toBe('audio/wav'); + }); + + it('should throw error for unsupported formats', async () => { + // @ts-expect-error - intentionally passing unsupported format + await expect(convertAudio(mockInputFile, 'flac')).rejects.toThrow( + 'Unsupported output format' + ); + }); +}); diff --git a/src/pages/tools/converters/audio-converter/index.tsx b/src/pages/tools/converters/audio-converter/index.tsx new file mode 100644 index 0000000..10f55a5 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/index.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { Box } from '@mui/material'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolAudioInput from '@components/input/ToolAudioInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import SelectWithDesc from '@components/options/SelectWithDesc'; +import { convertAudio } from './service'; +import { useTranslation } from 'react-i18next'; +import { GetGroupsType } from '@components/options/ToolOptions'; + +type InitialValuesType = { + outputFormat: 'mp3' | 'aac' | 'wav'; +}; + +const initialValues: InitialValuesType = { + outputFormat: 'mp3' +}; + +export default function AudioConverter({ + title, + longDescription +}: ToolComponentProps) { + const { t } = useTranslation('audio'); + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + // Explicitly type getGroups to match GetGroupsType + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + id: 'output-format', + title: t('audioConverter.outputFormat', 'Output Format'), + description: t( + 'audioConverter.outputFormatDescription', + 'Select the desired output audio format' + ), + component: ( + + + updateField( + 'outputFormat', + value as InitialValuesType['outputFormat'] + ) + } + options={[ + { label: 'MP3', value: 'mp3' }, + { label: 'AAC', value: 'aac' }, + { label: 'WAV', value: 'wav' } + ]} + description={t( + 'audioConverter.outputFormatDescription', + 'Select the desired output audio format' + )} + /> + + ) + } + ]; + + const compute = async ( + values: InitialValuesType, + inputFile: File | null + ): Promise => { + if (!inputFile) return; + + try { + setLoading(true); + const resultFile = await convertAudio(inputFile, values.outputFormat); + setResult(resultFile); + } catch (error) { + console.error('Conversion failed:', error); + setResult(null); + } finally { + setLoading(false); + } + }; + + return ( + + } + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ + title: `What is a ${title}?`, + description: longDescription + }} + /> + ); +} diff --git a/src/pages/tools/converters/audio-converter/meta.ts b/src/pages/tools/converters/audio-converter/meta.ts new file mode 100644 index 0000000..757fed4 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('converters', { + i18n: { + name: 'translation:converters.audioconverter.title', + description: 'translation:converters.audioconverter.description', + shortDescription: 'translation:converters.audioconverter.shortDescription', + longDescription: 'translation:converters.audioconverter.longDescription' + }, + path: 'audio-converter', + icon: 'mdi:music-note-outline', + keywords: ['audio', 'converter'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/converters/audio-converter/service.ts b/src/pages/tools/converters/audio-converter/service.ts new file mode 100644 index 0000000..e2676c3 --- /dev/null +++ b/src/pages/tools/converters/audio-converter/service.ts @@ -0,0 +1,100 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; + +const ffmpeg = new FFmpeg(); +let isLoaded = false; + +async function loadFFmpeg() { + if (!isLoaded) { + await ffmpeg.load(); + isLoaded = true; + } +} + +/** + * Converts input audio file to selected output format ('mp3', 'aac', or 'wav'). + * Supports any input audio file type accepted by FFmpeg. + * + * @param input - Source audio File + * @param outputFormat - 'mp3' | 'aac' | 'wav' + * @returns Converted audio File + */ +export async function convertAudio( + input: File, + outputFormat: 'mp3' | 'aac' | 'wav' +): Promise { + await loadFFmpeg(); + + // Use the original input extension for input filename + const inputExtMatch = input.name.match(/\.[^.]+$/); + const inputExt = inputExtMatch ? inputExtMatch[0] : '.audio'; + + const inputFileName = `input${inputExt}`; + const outputFileName = `output.${outputFormat}`; + + // Write the input file to FFmpeg FS + await ffmpeg.writeFile(inputFileName, await fetchFile(input)); + + // Build the FFmpeg args depending on the output format + // You can customize the codec and bitrate options per format here + let args: string[]; + + switch (outputFormat) { + case 'mp3': + args = [ + '-i', + inputFileName, + '-c:a', + 'libmp3lame', + '-b:a', + '192k', + outputFileName + ]; + break; + + case 'aac': + args = [ + '-i', + inputFileName, + '-c:a', + 'aac', + '-b:a', + '192k', + outputFileName + ]; + break; + + case 'wav': + args = ['-i', inputFileName, '-c:a', 'pcm_s16le', outputFileName]; + break; + + default: + throw new Error(`Unsupported output format: ${outputFormat}`); + } + + // Execute ffmpeg with arguments + await ffmpeg.exec(args); + + // Read the output file from FFmpeg FS + const data = await ffmpeg.readFile(outputFileName); + + // Determine MIME type by outputFormat + let mimeType = ''; + switch (outputFormat) { + case 'mp3': + mimeType = 'audio/mpeg'; + break; + case 'aac': + mimeType = 'audio/aac'; + break; + case 'wav': + mimeType = 'audio/wav'; + break; + } + + // Create a new File with the original name but new extension + const baseName = input.name.replace(/\.[^.]+$/, ''); + const convertedFileName = `${baseName}.${outputFormat}`; + + return new File([data], convertedFileName, { type: mimeType }); +} diff --git a/src/pages/tools/converters/index.ts b/src/pages/tools/converters/index.ts new file mode 100644 index 0000000..59ce53b --- /dev/null +++ b/src/pages/tools/converters/index.ts @@ -0,0 +1,3 @@ +import { tool as convertersAudioConverter } from './audio-converter/meta'; + +export const convertersTools = [convertersAudioConverter]; diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index ed19326..4f776a1 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -30,7 +30,8 @@ export type ToolCategory = | 'pdf' | 'image-generic' | 'audio' - | 'xml'; + | 'xml' + | 'converters'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index dd1b723..fff2b76 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -13,6 +13,7 @@ import { timeTools } from '../pages/tools/time'; import { IconifyIcon } from '@iconify/react'; import { pdfTools } from '../pages/tools/pdf'; import { xmlTools } from '../pages/tools/xml'; +import { convertersTools } from '../pages/tools/converters'; import { TFunction } from 'i18next'; import { FullI18nKey, I18nNamespaces } from '../i18n'; @@ -30,7 +31,8 @@ const toolCategoriesOrder: ToolCategory[] = [ 'png', 'time', 'xml', - 'gif' + 'gif', + 'converters' ]; export const tools: DefinedTool[] = [ ...imageTools, @@ -43,7 +45,8 @@ export const tools: DefinedTool[] = [ ...numberTools, ...timeTools, ...audioTools, - ...xmlTools + ...xmlTools, + ...convertersTools ]; const categoriesConfig: { type: ToolCategory; @@ -134,6 +137,12 @@ const categoriesConfig: { icon: 'mdi-light:xml', value: 'translation:categories.xml.description', title: 'translation:categories.xml.title' + }, + { + type: 'converters', + icon: 'mdi:swap-horizontal', + value: 'translation:categories.converters.description', + title: 'translation:categories.converters.title' } ]; // use for changelogs From 1a9252ffb582c25806d74c42295216b3700bc46c Mon Sep 17 00:00:00 2001 From: Srivarshan-T <140912234+Srivarshan-T@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:18:49 +0530 Subject: [PATCH 004/164] Update audio.json removed the previously added aactomp3 descriptions --- public/locales/en/audio.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/public/locales/en/audio.json b/public/locales/en/audio.json index 9a95626..17bf435 100644 --- a/public/locales/en/audio.json +++ b/public/locales/en/audio.json @@ -57,11 +57,5 @@ "title": "What is {{title}}?" }, "trimmingAudio": "Trimming Audio" - }, - "AACMP3": { - "title": "AAC to MP3", - "description": "convert AAC audio files to MP3 format.Which is a common audio format used for music and other audio files.", - "shortDescription": "convert AAC audio files to MP3 format.", - "longDescription": "convert AAC audio files to MP3 format. MP3 is a widely used audio format that provides good sound quality and is compatible with most devices and platforms. This tool allows you to convert your AAC files to MP3 easily, making it convenient for playback on various devices." - } + } } From 0ee424fe12c0ee47d3175a73298e6c5d5933cd2b Mon Sep 17 00:00:00 2001 From: Matus Date: Fri, 24 Oct 2025 17:32:07 +0200 Subject: [PATCH 005/164] Add full Images to PDF functionality with language updates and tests --- package-lock.json | 196 ++++++++++++++++- package.json | 1 + public/locales/de/pdf.json | 5 + public/locales/en/pdf.json | 5 + public/locales/es/pdf.json | 5 + public/locales/fr/pdf.json | 5 + public/locales/hi/pdf.json | 5 + public/locales/ja/pdf.json | 5 + public/locales/nl/pdf.json | 5 + public/locales/pt/pdf.json | 5 + public/locales/ru/pdf.json | 5 + public/locales/zh/pdf.json | 5 + .../convert-to-pdf.service.test.tsx | 70 ++++++ src/pages/tools/pdf/convert-to-pdf/index.tsx | 201 ++++++++++++++++++ src/pages/tools/pdf/convert-to-pdf/meta.ts | 32 +++ src/pages/tools/pdf/index.ts | 4 +- 16 files changed, 545 insertions(+), 9 deletions(-) create mode 100644 src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx create mode 100644 src/pages/tools/pdf/convert-to-pdf/index.tsx create mode 100644 src/pages/tools/pdf/convert-to-pdf/meta.ts diff --git a/package-lock.json b/package-lock.json index 51e2c83..250551e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "i18next-http-backend": "^3.0.2", "jimp": "^0.22.12", "js-quantities": "^1.8.0", + "jspdf": "^3.0.3", "jszip": "^3.10.1", "lint-staged": "^15.4.3", "locize": "^4.0.14", @@ -3487,6 +3488,11 @@ "integrity": "sha512-gDQJflz1rOgEcUXkMAl80bDGN46f5mp8GbcM5dyvq+zsFV6YRBRtmNxlJJ5mjY77T7BRkRFzdIBVmK90QYhCxA==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -3509,6 +3515,12 @@ "@types/node": "*" } }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.25.tgz", @@ -3523,7 +3535,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -3584,7 +3595,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", - "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -4448,6 +4458,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4737,6 +4756,25 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/centra": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", @@ -5170,6 +5208,17 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5268,6 +5317,15 @@ "node": ">=4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -5697,6 +5755,21 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dompurify/node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -5784,7 +5857,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "^0.6.2" @@ -6728,6 +6801,21 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fast-png/node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -6777,7 +6865,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -7553,6 +7640,19 @@ "void-elements": "3.1.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", @@ -7694,7 +7794,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7859,6 +7959,11 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", @@ -8589,6 +8694,22 @@ "node": "*" } }, + "node_modules/jspdf": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-3.0.3.tgz", + "integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==", + "dependencies": { + "@babel/runtime": "^7.26.9", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.2.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -9395,7 +9516,6 @@ "version": "0.53.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", "integrity": "sha512-0WNThgC6CMWNXXBxTbaYYcunj08iB5rnx4/G56UOPeL9UVIUGGHA1GR0EWIh9Ebabj7NpCRawQ5b0hfN1jQmYQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^1.0.6" @@ -10130,6 +10250,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, "node_modules/phin": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", @@ -10831,6 +10957,15 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/rc-slider": { "version": "11.1.9", "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", @@ -11406,6 +11541,15 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -11578,7 +11722,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -11908,6 +12052,15 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/start-server-and-test": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.2.tgz", @@ -12615,6 +12768,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -12716,6 +12878,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13045,7 +13216,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13173,6 +13344,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uzip": { "version": "0.20201231.0", "resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz", diff --git a/package.json b/package.json index d0f6d7e..8857a6e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "i18next-http-backend": "^3.0.2", "jimp": "^0.22.12", "js-quantities": "^1.8.0", + "jspdf": "^3.0.3", "jszip": "^3.10.1", "lint-staged": "^15.4.3", "locize": "^4.0.14", diff --git a/public/locales/de/pdf.json b/public/locales/de/pdf.json index 5d3ba8b..e811d7b 100644 --- a/public/locales/de/pdf.json +++ b/public/locales/de/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Konvertieren Sie PDF in PNG-Bilder", "title": "PDF zu PNG" }, + "convertToPdf": { + "title": "Bilder in PDF konvertieren", + "description": "Verschiedene Bildformate (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) in PDF konvertieren, mit Optionen zum Skalieren des Bildes und zur Wahl der Seitenorientierung.", + "shortDescription": "Bilder in PDF mit Skalierungs- und Orientierungskontrolle konvertieren" + }, "protectPdf": { "description": "Fügen Sie Ihren PDF-Dateien sicher in Ihrem Browser einen Passwortschutz hinzu", "shortDescription": "PDF-Dateien sicher mit einem Passwort schützen", diff --git a/public/locales/en/pdf.json b/public/locales/en/pdf.json index b0e9b30..82e5309 100644 --- a/public/locales/en/pdf.json +++ b/public/locales/en/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Convert PDF into PNG images", "title": "PDF to PNG" }, + "convertToPdf": { + "title": "Images to PDF", + "description": "Convert various image formats (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) to PDF, with options to scale the image and choose page orientation.", + "shortDescription": "Convert images to PDF with scale and orientation control" + }, "protectPdf": { "description": "Add password protection to your PDF files securely in your browser", "shortDescription": "Password protect PDF files securely", diff --git a/public/locales/es/pdf.json b/public/locales/es/pdf.json index 05a2f68..083fd01 100644 --- a/public/locales/es/pdf.json +++ b/public/locales/es/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Convertir PDF a imágenes PNG", "title": "PDF a PNG" }, + "convertToPdf": { + "title": "Imágenes a PDF", + "description": "Convertir varios formatos de imagen (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) a PDF, con opciones para escalar la imagen y elegir la orientación de la página.", + "shortDescription": "Convertir imágenes a PDF con control de escala y orientación" + }, "protectPdf": { "description": "Agregue protección con contraseña a sus archivos PDF de forma segura en su navegador", "shortDescription": "Proteger con contraseña los archivos PDF de forma segura", diff --git a/public/locales/fr/pdf.json b/public/locales/fr/pdf.json index b296a01..fac70af 100644 --- a/public/locales/fr/pdf.json +++ b/public/locales/fr/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Convertir des PDF en images PNG", "title": "PDF en PNG" }, + "convertToPdf": { + "title": "Images vers PDF", + "description": "Convertir divers formats d'image (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) en PDF, avec des options pour redimensionner l'image et choisir l'orientation de la page.", + "shortDescription": "Convertir des images en PDF avec contrôle de taille et d'orientation" + }, "protectPdf": { "description": "Ajoutez une protection par mot de passe à vos fichiers PDF en toute sécurité dans votre navigateur", "shortDescription": "Protégez les fichiers PDF en toute sécurité avec un mot de passe", diff --git a/public/locales/hi/pdf.json b/public/locales/hi/pdf.json index 10cf326..fdd808c 100644 --- a/public/locales/hi/pdf.json +++ b/public/locales/hi/pdf.json @@ -82,6 +82,11 @@ "shortDescription": "PDF को PNG छवियों में परिवर्तित करें", "title": "पीडीएफ से पीएनजी" }, + "convertToPdf": { + "title": "PDF में छवि बदलें", + "description": "विभिन्न छवि प्रारूपों (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) को PDF में परिवर्तित करें और छवि का आकार और पृष्ठ की अभिविन्यास समायोजित करें।", + "shortDescription": "छवियों को PDF में परिवर्तित करें, आकार और अभिविन्यास समायोजित करने योग्य" + }, "protectPdf": { "allowCopying": "कॉपी करने की अनुमति दें", "allowModification": "संशोधन की अनुमति दें", diff --git a/public/locales/ja/pdf.json b/public/locales/ja/pdf.json index 1a695a7..e421665 100644 --- a/public/locales/ja/pdf.json +++ b/public/locales/ja/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "PDFをPNG画像に変換する", "title": "PDFからPNGへ" }, + "convertToPdf": { + "description": "様々な画像形式(PNG、GIF、JPG、TIF、PSD、SVG、WEBP、HEIC、RAW)をPDFに変換します。画像サイズとページの向きを調整できます。", + "shortDescription": "画像を PDF に変換し、画像のサイズやページの向きを調整できます。", + "title": "画像を PDF に変換" + }, "protectPdf": { "description": "ブラウザで安全にPDFファイルにパスワード保護を追加します", "shortDescription": "PDFファイルをパスワードで安全に保護する", diff --git a/public/locales/nl/pdf.json b/public/locales/nl/pdf.json index 27dca31..2d8d020 100644 --- a/public/locales/nl/pdf.json +++ b/public/locales/nl/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "PDF naar PNG-afbeeldingen converteren", "title": "PDF naar PNG" }, + "convertToPdf": { + "title": "Afbeeldingen naar PDF", + "description": "Verschillende afbeeldingsformaten (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) naar PDF converteren, met opties om de afbeelding te schalen en de paginaoriëntatie te kiezen.", + "shortDescription": "Afbeeldingen naar PDF converteren met schaal- en oriëntatiecontrole" + }, "protectPdf": { "description": "Voeg wachtwoordbeveiliging toe aan uw PDF-bestanden veilig in uw browser", "shortDescription": "PDF-bestanden veilig met een wachtwoord beveiligen", diff --git a/public/locales/pt/pdf.json b/public/locales/pt/pdf.json index 2689460..f1a6a80 100644 --- a/public/locales/pt/pdf.json +++ b/public/locales/pt/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Converter PDF em imagens PNG", "title": "PDF para PNG" }, + "convertToPdf": { + "title": "Imagens para PDF", + "description": "Converter vários formatos de imagem (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) em PDF, com opções para redimensionar a imagem e escolher a orientação da página.", + "shortDescription": "Converter imagens em PDF com controle de escala e orientação" + }, "protectPdf": { "description": "Adicione proteção por senha aos seus arquivos PDF com segurança no seu navegador", "shortDescription": "Proteja arquivos PDF com senha com segurança", diff --git a/public/locales/ru/pdf.json b/public/locales/ru/pdf.json index 28d5d04..547cfdb 100644 --- a/public/locales/ru/pdf.json +++ b/public/locales/ru/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "Конвертировать PDF в изображения PNG", "title": "PDF в PNG" }, + "convertToPdf": { + "title": "Изображения в PDF", + "description": "Преобразовать различные форматы изображений (PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW) в PDF с возможностью масштабирования изображения и выбора ориентации страницы.", + "shortDescription": "Преобразовать изображения в PDF с управлением масштабом и ориентацией" + }, "protectPdf": { "description": "Добавьте надежную защиту паролем для ваших PDF-файлов в браузере.", "shortDescription": "Надежная защита паролем PDF-файлов", diff --git a/public/locales/zh/pdf.json b/public/locales/zh/pdf.json index 1e3d400..76c8c98 100644 --- a/public/locales/zh/pdf.json +++ b/public/locales/zh/pdf.json @@ -63,6 +63,11 @@ "shortDescription": "将 PDF 转换为 PNG 图像", "title": "PDF 转 PNG" }, + "convertToPdf": { + "title": "将图像转换为 PDF", + "description": "将各种图像格式(PNG, GIF, JPG, TIF, PSD, SVG, WEBP, HEIC, RAW)转换为 PDF,并可调整图像大小和页面方向。", + "shortDescription": "将图像转换为 PDF,可调整大小和方向" + }, "protectPdf": { "description": "在浏览器中安全地为 PDF 文件添加密码保护", "shortDescription": "使用密码安全地保护 PDF 文件", diff --git a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx new file mode 100644 index 0000000..82ace2d --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import ConvertToPdf from './index'; +import { vi } from 'vitest'; +import '@testing-library/jest-dom'; + +it('should render with default state values', () => { + render(); + expect(screen.getByLabelText(/A4 Page/i)).toBeChecked(); + expect(screen.getByLabelText(/Portrait/i)).toBeChecked(); + expect(screen.getByText(/Scale image: 100%/i)).toBeInTheDocument(); +}); + +it('should switch to full page type when selected', () => { + render(); + const fullOption = screen.getByLabelText(/Full Size/i); + fireEvent.click(fullOption); + expect(fullOption).toBeChecked(); +}); + +it('should update scale when slider moves', () => { + render(); + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: 80 } }); + expect(screen.getByText(/Scale image: 80%/i)).toBeInTheDocument(); +}); + +it('should change orientation to landscape', () => { + render(); + const landscapeRadio = screen.getByLabelText(/Landscape/i); + fireEvent.click(landscapeRadio); + expect(landscapeRadio).toBeChecked(); +}); + +vi.mock('jspdf', () => { + return { + default: vi.fn().mockImplementation(() => ({ + setDisplayMode: vi.fn(), + internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } }, + addImage: vi.fn(), + output: vi.fn().mockReturnValue(new Blob()) + })) + }; +}); + +it('should call jsPDF and addImage when compute is triggered', async () => { + const createObjectURLStub = vi + .spyOn(global.URL, 'createObjectURL') + .mockReturnValue('blob:url'); + + vi.mock('components/input/ToolImageInput', () => ({ + default: ({ onChange }: any) => ( + onChange(e.target.files[0])} + /> + ) + })); + + const mockFile = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' }); + render(); + + const fileInput = screen.getByTitle(/Input Image/i); + fireEvent.change(fileInput, { target: { files: [mockFile] } }); + + const jsPDF = (await import('jspdf')).default; + expect(jsPDF).toHaveBeenCalled(); + + createObjectURLStub.mockRestore(); +}); diff --git a/src/pages/tools/pdf/convert-to-pdf/index.tsx b/src/pages/tools/pdf/convert-to-pdf/index.tsx new file mode 100644 index 0000000..1e35a6c --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/index.tsx @@ -0,0 +1,201 @@ +import { + Box, + Slider, + Typography, + RadioGroup, + FormControlLabel, + Radio +} from '@mui/material'; +import ToolImageInput from 'components/input/ToolImageInput'; +import ToolFileResult from 'components/result/ToolFileResult'; +import React, { useState, useEffect } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import jsPDF from 'jspdf'; + +export default function ConvertToPdf({ title }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [scale, setScale] = useState(100); + const [orientation, setOrientation] = useState<'portrait' | 'landscape'>( + 'portrait' + ); + const [pageType, setPageType] = useState<'a4' | 'full'>('a4'); + const [imageSize, setImageSize] = useState<{ + widthMm: number; + heightMm: number; + widthPx: number; + heightPx: number; + } | null>(null); + + const compute = async (file: File | null, currentScale: number) => { + if (!file) return; + + const img = new Image(); + img.src = URL.createObjectURL(file); + + try { + await img.decode(); + + const pxToMm = (px: number) => px * 0.264583; + const imgWidthMm = pxToMm(img.width); + const imgHeightMm = pxToMm(img.height); + setImageSize({ + widthMm: imgWidthMm, + heightMm: imgHeightMm, + widthPx: img.width, + heightPx: img.height + }); + + const pdf = + pageType === 'full' + ? new jsPDF({ + orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait', + unit: 'mm', + format: [imgWidthMm, imgHeightMm] + }) + : new jsPDF({ + orientation, + unit: 'mm', + format: 'a4' + }); + + pdf.setDisplayMode('fullwidth'); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + + const widthRatio = pageWidth / img.width; + const heightRatio = pageHeight / img.height; + const fitScale = Math.min(widthRatio, heightRatio); + + const finalWidth = + pageType === 'full' + ? pageWidth + : img.width * fitScale * (currentScale / 100); + const finalHeight = + pageType === 'full' + ? pageHeight + : img.height * fitScale * (currentScale / 100); + + const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2; + const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2; + + pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight); + + const blob = pdf.output('blob'); + const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf'; + setResult(new File([blob], fileName, { type: 'application/pdf' })); + } catch (e) { + console.error(e); + } finally { + URL.revokeObjectURL(img.src); + } + }; + + useEffect(() => { + compute(input, scale); + }, [input, orientation, pageType]); + + return ( + + + + + PDF Type + setPageType(e.target.value as 'a4' | 'full')} + > + } + label="A4 Page" + /> + } + label="Full Size (Same as Image)" + /> + + + {pageType === 'full' && imageSize && ( + + Image size: {imageSize.widthMm.toFixed(1)} ×{' '} + {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} + {imageSize.heightPx} px) + + )} + + + {pageType === 'a4' && ( + <> + + Orientation + + setOrientation(e.target.value as 'portrait' | 'landscape') + } + > + } + label="Portrait (Vertical)" + /> + } + label="Landscape (Horizontal)" + /> + + + + + Scale image: {scale}% + setScale(v as number)} + onChangeCommitted={(_, v) => compute(input, v as number)} + min={10} + max={100} + step={1} + valueLabelDisplay="auto" + /> + + + )} + + } + resultComponent={ + + } + compute={() => compute(input, scale)} + setInput={setInput} + /> + ); +} diff --git a/src/pages/tools/pdf/convert-to-pdf/meta.ts b/src/pages/tools/pdf/convert-to-pdf/meta.ts new file mode 100644 index 0000000..0fa3879 --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/meta.ts @@ -0,0 +1,32 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('pdf', { + i18n: { + name: 'pdf:convertToPdf.title', + description: 'pdf:convertToPdf.description', + shortDescription: 'pdf:convertToPdf.shortDescription' + }, + + path: 'convert-to-pdf', + icon: 'ph:file-pdf-thin', + + keywords: [ + 'convert', + 'pdf', + 'image', + 'jpg', + 'jpeg', + 'png', + 'gif', + 'tiff', + 'webp', + 'heic', + 'raw', + 'psd', + 'svg', + 'quality', + 'compression' + ], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/pdf/index.ts b/src/pages/tools/pdf/index.ts index 6c3e03f..32583b9 100644 --- a/src/pages/tools/pdf/index.ts +++ b/src/pages/tools/pdf/index.ts @@ -7,6 +7,7 @@ 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'; +import { tool as convertToPdf } from './convert-to-pdf/meta'; export const pdfTools: DefinedTool[] = [ pdfEditor, @@ -16,5 +17,6 @@ export const pdfTools: DefinedTool[] = [ protectPdfTool, mergePdf, pdfToEpub, - pdfPdfToPng + pdfPdfToPng, + convertToPdf ]; From 691bef83043bf78a12358c19883f2c881c76cef2 Mon Sep 17 00:00:00 2001 From: Matus Date: Tue, 28 Oct 2025 16:13:45 +0100 Subject: [PATCH 006/164] refactor code structure - created types.ts and service.ts --- .../convert-to-pdf.service.test.tsx | 96 +++---- src/pages/tools/pdf/convert-to-pdf/index.tsx | 256 ++++++++---------- src/pages/tools/pdf/convert-to-pdf/service.ts | 80 ++++++ src/pages/tools/pdf/convert-to-pdf/types.ts | 21 ++ 4 files changed, 240 insertions(+), 213 deletions(-) create mode 100644 src/pages/tools/pdf/convert-to-pdf/service.ts create mode 100644 src/pages/tools/pdf/convert-to-pdf/types.ts diff --git a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx index 82ace2d..107039a 100644 --- a/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx +++ b/src/pages/tools/pdf/convert-to-pdf/convert-to-pdf.service.test.tsx @@ -3,68 +3,36 @@ import ConvertToPdf from './index'; import { vi } from 'vitest'; import '@testing-library/jest-dom'; -it('should render with default state values', () => { - render(); - expect(screen.getByLabelText(/A4 Page/i)).toBeChecked(); - expect(screen.getByLabelText(/Portrait/i)).toBeChecked(); - expect(screen.getByText(/Scale image: 100%/i)).toBeInTheDocument(); -}); - -it('should switch to full page type when selected', () => { - render(); - const fullOption = screen.getByLabelText(/Full Size/i); - fireEvent.click(fullOption); - expect(fullOption).toBeChecked(); -}); - -it('should update scale when slider moves', () => { - render(); - const slider = screen.getByRole('slider'); - fireEvent.change(slider, { target: { value: 80 } }); - expect(screen.getByText(/Scale image: 80%/i)).toBeInTheDocument(); -}); - -it('should change orientation to landscape', () => { - render(); - const landscapeRadio = screen.getByLabelText(/Landscape/i); - fireEvent.click(landscapeRadio); - expect(landscapeRadio).toBeChecked(); -}); - -vi.mock('jspdf', () => { - return { - default: vi.fn().mockImplementation(() => ({ - setDisplayMode: vi.fn(), - internal: { pageSize: { getWidth: () => 210, getHeight: () => 297 } }, - addImage: vi.fn(), - output: vi.fn().mockReturnValue(new Blob()) - })) - }; -}); - -it('should call jsPDF and addImage when compute is triggered', async () => { - const createObjectURLStub = vi - .spyOn(global.URL, 'createObjectURL') - .mockReturnValue('blob:url'); - - vi.mock('components/input/ToolImageInput', () => ({ - default: ({ onChange }: any) => ( - onChange(e.target.files[0])} - /> - ) - })); - - const mockFile = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' }); - render(); - - const fileInput = screen.getByTitle(/Input Image/i); - fireEvent.change(fileInput, { target: { files: [mockFile] } }); - - const jsPDF = (await import('jspdf')).default; - expect(jsPDF).toHaveBeenCalled(); - - createObjectURLStub.mockRestore(); +describe('ConvertToPdf', () => { + it('renders with default state values (full, portrait hidden, no scale shown)', () => { + render(); + + expect(screen.getByLabelText(/Full Size \(Same as Image\)/i)).toBeChecked(); + + expect(screen.queryByLabelText(/A4 Page/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Portrait/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Scale image:/i)).not.toBeInTheDocument(); + }); + + it('switches to A4 page type and shows orientation and scale', () => { + render(); + + const a4Option = screen.getByLabelText(/A4 Page/i); + fireEvent.click(a4Option); + expect(a4Option).toBeChecked(); + + expect(screen.getByLabelText(/Portrait/i)).toBeChecked(); + expect(screen.getByText(/Scale image:\s*100%/i)).toBeInTheDocument(); + }); + + it('updates scale when slider moves (after switching to A4)', () => { + render(); + + fireEvent.click(screen.getByLabelText(/A4 Page/i)); + + const slider = screen.getByRole('slider'); + fireEvent.change(slider, { target: { value: 80 } }); + + expect(screen.getByText(/Scale image:\s*80%/i)).toBeInTheDocument(); + }); }); diff --git a/src/pages/tools/pdf/convert-to-pdf/index.tsx b/src/pages/tools/pdf/convert-to-pdf/index.tsx index 1e35a6c..750e843 100644 --- a/src/pages/tools/pdf/convert-to-pdf/index.tsx +++ b/src/pages/tools/pdf/convert-to-pdf/index.tsx @@ -4,23 +4,22 @@ import { Typography, RadioGroup, FormControlLabel, - Radio + Radio, + Stack } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; import ToolImageInput from 'components/input/ToolImageInput'; import ToolFileResult from 'components/result/ToolFileResult'; -import React, { useState, useEffect } from 'react'; -import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; -import jsPDF from 'jspdf'; +import { FormValues, Orientation, PageType, initialValues } from './types'; +import { buildPdf } from './service'; + +const initialFormValues: FormValues = initialValues; export default function ConvertToPdf({ title }: ToolComponentProps) { const [input, setInput] = useState(null); const [result, setResult] = useState(null); - const [scale, setScale] = useState(100); - const [orientation, setOrientation] = useState<'portrait' | 'landscape'>( - 'portrait' - ); - const [pageType, setPageType] = useState<'a4' | 'full'>('a4'); const [imageSize, setImageSize] = useState<{ widthMm: number; heightMm: number; @@ -28,81 +27,27 @@ export default function ConvertToPdf({ title }: ToolComponentProps) { heightPx: number; } | null>(null); - const compute = async (file: File | null, currentScale: number) => { - if (!file) return; - - const img = new Image(); - img.src = URL.createObjectURL(file); - - try { - await img.decode(); - - const pxToMm = (px: number) => px * 0.264583; - const imgWidthMm = pxToMm(img.width); - const imgHeightMm = pxToMm(img.height); - setImageSize({ - widthMm: imgWidthMm, - heightMm: imgHeightMm, - widthPx: img.width, - heightPx: img.height - }); - - const pdf = - pageType === 'full' - ? new jsPDF({ - orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait', - unit: 'mm', - format: [imgWidthMm, imgHeightMm] - }) - : new jsPDF({ - orientation, - unit: 'mm', - format: 'a4' - }); - - pdf.setDisplayMode('fullwidth'); - - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - - const widthRatio = pageWidth / img.width; - const heightRatio = pageHeight / img.height; - const fitScale = Math.min(widthRatio, heightRatio); - - const finalWidth = - pageType === 'full' - ? pageWidth - : img.width * fitScale * (currentScale / 100); - const finalHeight = - pageType === 'full' - ? pageHeight - : img.height * fitScale * (currentScale / 100); - - const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2; - const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2; - - pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight); - - const blob = pdf.output('blob'); - const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf'; - setResult(new File([blob], fileName, { type: 'application/pdf' })); - } catch (e) { - console.error(e); - } finally { - URL.revokeObjectURL(img.src); - } + const compute = async (values: FormValues) => { + if (!input) return; + const { pdfFile, imageSize } = await buildPdf({ + file: input, + pageType: values.pageType, + orientation: values.orientation, + scale: values.scale + }); + setResult(pdfFile); + setImageSize(imageSize); }; - useEffect(() => { - compute(input, scale); - }, [input, orientation, pageType]); - return ( - title={title} input={input} + setInput={setInput} + initialValues={initialFormValues} + compute={compute} inputComponent={ - + - - - PDF Type - setPageType(e.target.value as 'a4' | 'full')} - > - } - label="A4 Page" - /> - } - label="Full Size (Same as Image)" - /> - - - {pageType === 'full' && imageSize && ( - - Image size: {imageSize.widthMm.toFixed(1)} ×{' '} - {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} - {imageSize.heightPx} px) - - )} - - - {pageType === 'a4' && ( - <> - - Orientation - - setOrientation(e.target.value as 'portrait' | 'landscape') - } - > - } - label="Portrait (Vertical)" - /> - } - label="Landscape (Horizontal)" - /> - - - - - Scale image: {scale}% - setScale(v as number)} - onChangeCommitted={(_, v) => compute(input, v as number)} - min={10} - max={100} - step={1} - valueLabelDisplay="auto" - /> - - - )} } + getGroups={({ values, updateField }) => { + return [ + { + title: '', + component: ( + + + PDF Type + + updateField('pageType', e.target.value as PageType) + } + > + } + label="Full Size (Same as Image)" + /> + } + label="A4 Page" + /> + + + {values.pageType === 'full' && imageSize && ( + + Image size: {imageSize.widthMm.toFixed(1)} ×{' '} + {imageSize.heightMm.toFixed(1)} mm ({imageSize.widthPx} ×{' '} + {imageSize.heightPx} px) + + )} + + + {values.pageType === 'a4' && ( + + Orientation + + updateField( + 'orientation', + e.target.value as Orientation + ) + } + > + } + label="Portrait (Vertical)" + /> + } + label="Landscape (Horizontal)" + /> + + + )} + + {values.pageType === 'a4' && ( + + Scale + Scale image: {values.scale}% + updateField('scale', v as number)} + min={10} + max={100} + step={1} + valueLabelDisplay="auto" + /> + + )} + + ) + } + ] as const; + }} resultComponent={ - + } - compute={() => compute(input, scale)} - setInput={setInput} /> ); } diff --git a/src/pages/tools/pdf/convert-to-pdf/service.ts b/src/pages/tools/pdf/convert-to-pdf/service.ts new file mode 100644 index 0000000..4a1eb22 --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/service.ts @@ -0,0 +1,80 @@ +import jsPDF from 'jspdf'; +import { Orientation, PageType, ImageSize } from './types'; + +export interface ComputeOptions { + file: File; + pageType: PageType; + orientation: Orientation; + scale: number; // 10..100 (only applied for A4) +} + +export interface ComputeResult { + pdfFile: File; + imageSize: ImageSize; +} + +export async function buildPdf({ + file, + pageType, + orientation, + scale +}: ComputeOptions): Promise { + const img = new Image(); + img.src = URL.createObjectURL(file); + + try { + await img.decode(); + + const pxToMm = (px: number) => px * 0.264583; + const imgWidthMm = pxToMm(img.width); + const imgHeightMm = pxToMm(img.height); + + const pdf = + pageType === 'full' + ? new jsPDF({ + orientation: imgWidthMm > imgHeightMm ? 'landscape' : 'portrait', + unit: 'mm', + format: [imgWidthMm, imgHeightMm] + }) + : new jsPDF({ + orientation, + unit: 'mm', + format: 'a4' + }); + + pdf.setDisplayMode('fullwidth'); + + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + + const widthRatio = pageWidth / img.width; + const heightRatio = pageHeight / img.height; + const fitScale = Math.min(widthRatio, heightRatio); + + const finalWidth = + pageType === 'full' ? pageWidth : img.width * fitScale * (scale / 100); + + const finalHeight = + pageType === 'full' ? pageHeight : img.height * fitScale * (scale / 100); + + const x = pageType === 'full' ? 0 : (pageWidth - finalWidth) / 2; + const y = pageType === 'full' ? 0 : (pageHeight - finalHeight) / 2; + + pdf.addImage(img, 'JPEG', x, y, finalWidth, finalHeight); + + const blob = pdf.output('blob'); + const fileName = file.name.replace(/\.[^/.]+$/, '') + '.pdf'; + + return { + pdfFile: new File([blob], fileName, { type: 'application/pdf' }), + imageSize: { + widthMm: imgWidthMm, + heightMm: imgHeightMm, + widthPx: img.width, + heightPx: img.height + } + }; + } finally { + URL.revokeObjectURL(img.src); + } +} diff --git a/src/pages/tools/pdf/convert-to-pdf/types.ts b/src/pages/tools/pdf/convert-to-pdf/types.ts new file mode 100644 index 0000000..93b9a42 --- /dev/null +++ b/src/pages/tools/pdf/convert-to-pdf/types.ts @@ -0,0 +1,21 @@ +export type Orientation = 'portrait' | 'landscape'; +export type PageType = 'a4' | 'full'; + +export interface ImageSize { + widthMm: number; + heightMm: number; + widthPx: number; + heightPx: number; +} + +export interface FormValues { + pageType: PageType; + orientation: Orientation; + scale: number; +} + +export const initialValues: FormValues = { + pageType: 'full', + orientation: 'portrait', + scale: 100 +}; From f30aeb45eb180004a871241231490a7482119698 Mon Sep 17 00:00:00 2001 From: Learncold Date: Wed, 5 Nov 2025 15:12:58 +0900 Subject: [PATCH 007/164] Add unicode encoder/decoder tool --- public/locales/en/string.json | 17 ++ src/pages/tools/string/index.ts | 2 + src/pages/tools/string/unicode/index.tsx | 131 +++++++++++++ src/pages/tools/string/unicode/meta.ts | 15 ++ src/pages/tools/string/unicode/service.ts | 28 +++ src/pages/tools/string/unicode/types.ts | 4 + .../string/unicode/unicode.service.test.ts | 178 ++++++++++++++++++ 7 files changed, 375 insertions(+) create mode 100644 src/pages/tools/string/unicode/index.tsx create mode 100644 src/pages/tools/string/unicode/meta.ts create mode 100644 src/pages/tools/string/unicode/service.ts create mode 100644 src/pages/tools/string/unicode/types.ts create mode 100644 src/pages/tools/string/unicode/unicode.service.test.ts diff --git a/public/locales/en/string.json b/public/locales/en/string.json index c6176c1..9037edc 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -307,5 +307,22 @@ "shortDescription": "Quickly URL-escape a string.", "title": "String URL encoder" } + }, + "unicode": { + "title": "Unicode Encoder/Decoder", + "description": "Encode text to Unicode escape sequences and decode them back.", + "shortDescription": "Encode or decode text using Unicode escape sequences.", + "longDescription": "This tool allows you to encode plain text into Unicode escape sequences (e.g., \\uXXXX) and decode them back. You can choose to output the hexadecimal digits in either uppercase or lowercase for encoding.", + "inputTitle": "Input", + "resultTitle": "Output", + "optionsTitle": "Options", + "caseOptionsTitle": "Case Options", + "encode": "Unicode Encode", + "decode": "Unicode Decode", + "uppercase": "Uppercase Hex", + "toolInfo": { + "title": "What is Unicode Escape?", + "description": "Unicode escape sequences are used to represent characters that may not be available in the character set of the document. They are written as '\\u' followed by four hexadecimal digits." + } } } diff --git a/src/pages/tools/string/index.ts b/src/pages/tools/string/index.ts index 1134b14..ac067c9 100644 --- a/src/pages/tools/string/index.ts +++ b/src/pages/tools/string/index.ts @@ -21,6 +21,7 @@ import { tool as stringCensor } from './censor/meta'; import { tool as stringPasswordGenerator } from './password-generator/meta'; import { tool as stringEncodeUrl } from './url-encode/meta'; import { tool as StringDecodeUrl } from './url-decode/meta'; +import { tool as stringUnicode } from './unicode/meta'; export const stringTools = [ stringSplit, @@ -45,5 +46,6 @@ export const stringTools = [ stringPasswordGenerator, stringEncodeUrl, StringDecodeUrl, + stringUnicode, stringHiddenCharacterDetector ]; diff --git a/src/pages/tools/string/unicode/index.tsx b/src/pages/tools/string/unicode/index.tsx new file mode 100644 index 0000000..bd74fd1 --- /dev/null +++ b/src/pages/tools/string/unicode/index.tsx @@ -0,0 +1,131 @@ +import { Box, FormControlLabel, Switch } from '@mui/material'; +import { 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 { unicode } from './service'; +import { InitialValuesType } from './types'; +import SimpleRadio from '@components/options/SimpleRadio'; +import { useTranslation } from 'react-i18next'; + +const initialValues: InitialValuesType = { + mode: 'encode', + uppercase: false +}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Encode to Unicode Escape', + description: 'Encode plain text to Unicode escape sequences.', + sampleText: 'Hello, World!', + sampleResult: + '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021', + sampleOptions: { + mode: 'encode', + uppercase: false + } + }, + { + title: 'Encode to Unicode Escape (Uppercase)', + description: 'Encode plain text to uppercase Unicode escape sequences.', + sampleText: 'Hello, World!', + sampleResult: + '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021'.toUpperCase(), + sampleOptions: { + mode: 'encode', + uppercase: true + } + }, + { + title: 'Decode Unicode Escape', + description: 'Decode Unicode escape sequences back to plain text.', + sampleText: + '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021', + sampleResult: 'Hello, World!', + sampleOptions: { + mode: 'decode', + uppercase: false + } + } +]; +export default function Unicode({ + title, + longDescription +}: ToolComponentProps) { + const { t } = useTranslation('string'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (values: InitialValuesType, input: string) => { + if (!input) return; + setResult(unicode(input, values.mode === 'encode', values.uppercase)); + }; + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: t('unicode.optionsTitle'), + component: ( + + updateField('mode', 'encode')} + checked={values.mode === 'encode'} + title={t('unicode.encode')} + /> + updateField('mode', 'decode')} + checked={values.mode === 'decode'} + title={t('unicode.decode')} + /> + + ) + }, + { + title: t('unicode.caseOptionsTitle'), + component: ( + + updateField('uppercase', e.target.checked)} + disabled={values.mode === 'decode'} + /> + } + label={t('unicode.uppercase')} + /> + + ) + } + ]; + return ( + + } + resultComponent={ + + } + initialValues={initialValues} + exampleCards={exampleCards} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ + title: t('unicode.toolInfo.title'), + description: t('unicode.toolInfo.description') + }} + /> + ); +} diff --git a/src/pages/tools/string/unicode/meta.ts b/src/pages/tools/string/unicode/meta.ts new file mode 100644 index 0000000..069d5fa --- /dev/null +++ b/src/pages/tools/string/unicode/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('string', { + i18n: { + name: 'string:unicode.title', + description: 'string:unicode.description', + shortDescription: 'string:unicode.shortDescription', + longDescription: 'string:unicode.longDescription' + }, + path: 'unicode', + icon: 'mdi:unicode', + keywords: ['unicode'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/string/unicode/service.ts b/src/pages/tools/string/unicode/service.ts new file mode 100644 index 0000000..9943f64 --- /dev/null +++ b/src/pages/tools/string/unicode/service.ts @@ -0,0 +1,28 @@ +import { InitialValuesType } from './types'; + +export function main(input: string, options: InitialValuesType): string { + return input; +} + +export function unicode( + input: string, + encode: boolean, + uppercase: boolean +): string { + if (encode) { + let result = ''; + for (let i = 0; i < input.length; i++) { + let hex = input.charCodeAt(i).toString(16); + hex = ('0000' + hex).slice(-4); + if (uppercase) { + hex = hex.toUpperCase(); + } + result += '\\u' + hex; + } + return result; + } else { + return input.replace(/\\u([\dA-Fa-f]{4})/g, (match, grp) => { + return String.fromCharCode(parseInt(grp, 16)); + }); + } +} diff --git a/src/pages/tools/string/unicode/types.ts b/src/pages/tools/string/unicode/types.ts new file mode 100644 index 0000000..dd39d11 --- /dev/null +++ b/src/pages/tools/string/unicode/types.ts @@ -0,0 +1,4 @@ +export type InitialValuesType = { + mode: 'encode' | 'decode'; + uppercase: boolean; +}; diff --git a/src/pages/tools/string/unicode/unicode.service.test.ts b/src/pages/tools/string/unicode/unicode.service.test.ts new file mode 100644 index 0000000..97f90a3 --- /dev/null +++ b/src/pages/tools/string/unicode/unicode.service.test.ts @@ -0,0 +1,178 @@ +import { expect, describe, it } from 'vitest'; +import { unicode } from './service'; + +describe('unicode', () => { + it('should encode a English string to lowercase hex correctly', () => { + const input = 'Hello, World!'; + const result = unicode(input, true, false); + expect(result).toBe( + '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021' + ); + }); + + it('should encode a English string to uppercase hex correctly', () => { + const input = 'Hello, World!'; + const result = unicode(input, true, true); + expect(result).toBe( + '\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021' + ); + }); + + it('should decode a English lowercase hex string correctly', () => { + const input = + '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('Hello, World!'); + }); + + it('should decode a English uppercase hex string correctly', () => { + const input = + '\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('Hello, World!'); + }); + + it('should encode a Korean string to lowercase hex correctly', () => { + const input = '안녕하세요, 세계!'; + const result = unicode(input, true, false); + expect(result).toBe( + '\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021' + ); + }); + + it('should encode a Korean string to uppercase hex correctly', () => { + const input = '안녕하세요, 세계!'; + const result = unicode(input, true, true); + expect(result).toBe( + '\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021' + ); + }); + + it('should decode a Korean lowercase hex string correctly', () => { + const input = + '\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('안녕하세요, 세계!'); + }); + + it('should decode a Korean uppercase hex string correctly', () => { + const input = + '\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('안녕하세요, 세계!'); + }); + + it('should encode a Japanese string to lowercase hex correctly', () => { + const input = 'こんにちは、世界!'; + const result = unicode(input, true, false); + expect(result).toBe( + '\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01' + ); + }); + + it('should encode a Japanese string to uppercase hex correctly', () => { + const input = 'こんにちは、世界!'; + const result = unicode(input, true, true); + expect(result).toBe( + '\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01' + ); + }); + + it('should decode a Japanese lowercase hex string correctly', () => { + const input = + '\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01'; + const result = unicode(input, false, false); + expect(result).toBe('こんにちは、世界!'); + }); + + it('should decode a Japanese uppercase hex string correctly', () => { + const input = + '\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01'; + const result = unicode(input, false, false); + expect(result).toBe('こんにちは、世界!'); + }); + + it('should encode a Chinese string to lowercase hex correctly', () => { + const input = '你好,世界!'; + const result = unicode(input, true, false); + expect(result).toBe('\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'); + }); + + it('should encode a Chinese string to uppercase hex correctly', () => { + const input = '你好,世界!'; + const result = unicode(input, true, true); + expect(result).toBe('\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01'); + }); + + it('should decode a Chinese lowercase hex string correctly', () => { + const input = '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'; + const result = unicode(input, false, false); + expect(result).toBe('你好,世界!'); + }); + + it('should decode a Chinese uppercase hex string correctly', () => { + const input = '\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01'; + const result = unicode(input, false, false); + expect(result).toBe('你好,世界!'); + }); + + it('should encode a Russian string to lowercase hex correctly', () => { + const input = 'Привет, мир!'; + const result = unicode(input, true, false); + expect(result).toBe( + '\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021' + ); + }); + + it('should encode a Russian string to uppercase hex correctly', () => { + const input = 'Привет, мир!'; + const result = unicode(input, true, true); + expect(result).toBe( + '\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021' + ); + }); + + it('should decode a Russian lowercase hex string correctly', () => { + const input = + '\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('Привет, мир!'); + }); + + it('should decode a Russian uppercase hex string correctly', () => { + const input = + '\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('Привет, мир!'); + }); + + it('should encode a Spanish string to lowercase hex correctly', () => { + const input = '¡Hola, Mundo!'; + const result = unicode(input, true, false); + expect(result).toBe( + '\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021' + ); + }); + + it('should encode a Spanish string to uppercase hex correctly', () => { + const input = '¡Hola, Mundo!'; + const result = unicode(input, true, true); + expect(result).toBe( + '\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021' + ); + }); + + it('should decode a Spanish lowercase hex string correctly', () => { + const input = + '\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('¡Hola, Mundo!'); + }); + + it('should decode a Spanish uppercase hex string correctly', () => { + const input = + '\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021'; + const result = unicode(input, false, false); + expect(result).toBe('¡Hola, Mundo!'); + }); +}); From 8cc7986c7f461faffcfa85c8cb3080dc6a61b6fa Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 30 Nov 2025 17:55:49 +0100 Subject: [PATCH 008/164] tests: rewritten test file to match main function struct --- .../string/unicode/unicode.service.test.ts | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/pages/tools/string/unicode/unicode.service.test.ts b/src/pages/tools/string/unicode/unicode.service.test.ts index 97f90a3..1d43163 100644 --- a/src/pages/tools/string/unicode/unicode.service.test.ts +++ b/src/pages/tools/string/unicode/unicode.service.test.ts @@ -2,39 +2,39 @@ import { expect, describe, it } from 'vitest'; import { unicode } from './service'; describe('unicode', () => { - it('should encode a English string to lowercase hex correctly', () => { + it('should encode an English string to lowercase hex correctly', () => { const input = 'Hello, World!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe( '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021' ); }); - it('should encode a English string to uppercase hex correctly', () => { + it('should encode an English string to uppercase hex correctly', () => { const input = 'Hello, World!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe( '\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021' ); }); - it('should decode a English lowercase hex string correctly', () => { + it('should decode an English lowercase hex string correctly', () => { const input = '\\u0048\\u0065\\u006c\\u006c\\u006f\\u002c\\u0020\\u0057\\u006f\\u0072\\u006c\\u0064\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('Hello, World!'); }); - it('should decode a English uppercase hex string correctly', () => { + it('should decode an English uppercase hex string correctly', () => { const input = '\\u0048\\u0065\\u006C\\u006C\\u006F\\u002C\\u0020\\u0057\\u006F\\u0072\\u006C\\u0064\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('Hello, World!'); }); it('should encode a Korean string to lowercase hex correctly', () => { const input = '안녕하세요, 세계!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe( '\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021' ); @@ -42,7 +42,7 @@ describe('unicode', () => { it('should encode a Korean string to uppercase hex correctly', () => { const input = '안녕하세요, 세계!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe( '\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021' ); @@ -51,20 +51,20 @@ describe('unicode', () => { it('should decode a Korean lowercase hex string correctly', () => { const input = '\\uc548\\ub155\\ud558\\uc138\\uc694\\u002c\\u0020\\uc138\\uacc4\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('안녕하세요, 세계!'); }); it('should decode a Korean uppercase hex string correctly', () => { const input = '\\uC548\\uB155\\uD558\\uC138\\uC694\\u002C\\u0020\\uC138\\uACC4\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('안녕하세요, 세계!'); }); it('should encode a Japanese string to lowercase hex correctly', () => { const input = 'こんにちは、世界!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe( '\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01' ); @@ -72,7 +72,7 @@ describe('unicode', () => { it('should encode a Japanese string to uppercase hex correctly', () => { const input = 'こんにちは、世界!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe( '\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01' ); @@ -81,44 +81,44 @@ describe('unicode', () => { it('should decode a Japanese lowercase hex string correctly', () => { const input = '\\u3053\\u3093\\u306b\\u3061\\u306f\\u3001\\u4e16\\u754c\\uff01'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('こんにちは、世界!'); }); it('should decode a Japanese uppercase hex string correctly', () => { const input = '\\u3053\\u3093\\u306B\\u3061\\u306F\\u3001\\u4E16\\u754C\\uFF01'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('こんにちは、世界!'); }); it('should encode a Chinese string to lowercase hex correctly', () => { const input = '你好,世界!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe('\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'); }); it('should encode a Chinese string to uppercase hex correctly', () => { const input = '你好,世界!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe('\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01'); }); it('should decode a Chinese lowercase hex string correctly', () => { const input = '\\u4f60\\u597d\\uff0c\\u4e16\\u754c\\uff01'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('你好,世界!'); }); it('should decode a Chinese uppercase hex string correctly', () => { const input = '\\u4F60\\u597D\\uFF0C\\u4E16\\u754C\\uFF01'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('你好,世界!'); }); it('should encode a Russian string to lowercase hex correctly', () => { const input = 'Привет, мир!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe( '\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021' ); @@ -126,7 +126,7 @@ describe('unicode', () => { it('should encode a Russian string to uppercase hex correctly', () => { const input = 'Привет, мир!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe( '\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021' ); @@ -135,20 +135,20 @@ describe('unicode', () => { it('should decode a Russian lowercase hex string correctly', () => { const input = '\\u041f\\u0440\\u0438\\u0432\\u0435\\u0442\\u002c\\u0020\\u043c\\u0438\\u0440\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('Привет, мир!'); }); it('should decode a Russian uppercase hex string correctly', () => { const input = '\\u041F\\u0440\\u0438\\u0432\\u0435\\u0442\\u002C\\u0020\\u043C\\u0438\\u0440\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('Привет, мир!'); }); it('should encode a Spanish string to lowercase hex correctly', () => { const input = '¡Hola, Mundo!'; - const result = unicode(input, true, false); + const result = unicode(input, { mode: 'encode', uppercase: false }); expect(result).toBe( '\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021' ); @@ -156,7 +156,7 @@ describe('unicode', () => { it('should encode a Spanish string to uppercase hex correctly', () => { const input = '¡Hola, Mundo!'; - const result = unicode(input, true, true); + const result = unicode(input, { mode: 'encode', uppercase: true }); expect(result).toBe( '\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021' ); @@ -165,14 +165,14 @@ describe('unicode', () => { it('should decode a Spanish lowercase hex string correctly', () => { const input = '\\u00a1\\u0048\\u006f\\u006c\\u0061\\u002c\\u0020\\u004d\\u0075\\u006e\\u0064\\u006f\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('¡Hola, Mundo!'); }); it('should decode a Spanish uppercase hex string correctly', () => { const input = '\\u00A1\\u0048\\u006F\\u006C\\u0061\\u002C\\u0020\\u004D\\u0075\\u006E\\u0064\\u006F\\u0021'; - const result = unicode(input, false, false); + const result = unicode(input, { mode: 'decode', uppercase: false }); expect(result).toBe('¡Hola, Mundo!'); }); }); From ef034f2b8b94a9ba60ce8a9a18b7febc778f3062 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 30 Nov 2025 17:56:43 +0100 Subject: [PATCH 009/164] chore: void input edge case added to service --- src/pages/tools/string/unicode/service.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/pages/tools/string/unicode/service.ts b/src/pages/tools/string/unicode/service.ts index 9943f64..818f832 100644 --- a/src/pages/tools/string/unicode/service.ts +++ b/src/pages/tools/string/unicode/service.ts @@ -1,20 +1,13 @@ import { InitialValuesType } from './types'; -export function main(input: string, options: InitialValuesType): string { - return input; -} - -export function unicode( - input: string, - encode: boolean, - uppercase: boolean -): string { - if (encode) { +export function unicode(input: string, options: InitialValuesType): string { + if (!input) return ''; + if (options.mode === 'encode') { let result = ''; for (let i = 0; i < input.length; i++) { let hex = input.charCodeAt(i).toString(16); hex = ('0000' + hex).slice(-4); - if (uppercase) { + if (options.uppercase) { hex = hex.toUpperCase(); } result += '\\u' + hex; From 6ad57674ea512b69a7c27914a30bf626b258c243 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 30 Nov 2025 17:57:48 +0100 Subject: [PATCH 010/164] chore: keywords added to list --- src/pages/tools/string/unicode/meta.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/tools/string/unicode/meta.ts b/src/pages/tools/string/unicode/meta.ts index 069d5fa..8aa0c6a 100644 --- a/src/pages/tools/string/unicode/meta.ts +++ b/src/pages/tools/string/unicode/meta.ts @@ -5,11 +5,10 @@ export const tool = defineTool('string', { i18n: { name: 'string:unicode.title', description: 'string:unicode.description', - shortDescription: 'string:unicode.shortDescription', - longDescription: 'string:unicode.longDescription' + shortDescription: 'string:unicode.shortDescription' }, path: 'unicode', icon: 'mdi:unicode', - keywords: ['unicode'], + keywords: ['unicode', 'encode', 'decode', 'escape', 'text'], component: lazy(() => import('./index')) }); From 009a0dd6ad329041cfe11c7ce09774852a031188 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 30 Nov 2025 17:59:06 +0100 Subject: [PATCH 011/164] chore: checkbox used instead of switch --- src/pages/tools/string/unicode/index.tsx | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/pages/tools/string/unicode/index.tsx b/src/pages/tools/string/unicode/index.tsx index bd74fd1..1a38e87 100644 --- a/src/pages/tools/string/unicode/index.tsx +++ b/src/pages/tools/string/unicode/index.tsx @@ -1,4 +1,4 @@ -import { Box, FormControlLabel, Switch } from '@mui/material'; +import { Box } from '@mui/material'; import { useState } from 'react'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; @@ -9,6 +9,7 @@ import { CardExampleType } from '@components/examples/ToolExamples'; import { unicode } from './service'; import { InitialValuesType } from './types'; import SimpleRadio from '@components/options/SimpleRadio'; +import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; import { useTranslation } from 'react-i18next'; const initialValues: InitialValuesType = { @@ -51,17 +52,13 @@ const exampleCards: CardExampleType[] = [ } } ]; -export default function Unicode({ - title, - longDescription -}: ToolComponentProps) { +export default function Unicode({ title }: ToolComponentProps) { const { t } = useTranslation('string'); const [input, setInput] = useState(''); const [result, setResult] = useState(''); const compute = (values: InitialValuesType, input: string) => { - if (!input) return; - setResult(unicode(input, values.mode === 'encode', values.uppercase)); + setResult(unicode(input, values)); }; const getGroups: GetGroupsType = ({ @@ -89,15 +86,10 @@ export default function Unicode({ title: t('unicode.caseOptionsTitle'), component: ( - updateField('uppercase', e.target.checked)} - disabled={values.mode === 'decode'} - /> - } - label={t('unicode.uppercase')} + updateField('uppercase', value)} + title={t('unicode.uppercase')} /> ) From 80662c59c61bc98836061121af979884e6c10316 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 30 Nov 2025 18:00:03 +0100 Subject: [PATCH 012/164] locales: desc minor changes --- public/locales/en/string.json | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/public/locales/en/string.json b/public/locales/en/string.json index 9037edc..deb649a 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -309,20 +309,19 @@ } }, "unicode": { - "title": "Unicode Encoder/Decoder", - "description": "Encode text to Unicode escape sequences and decode them back.", - "shortDescription": "Encode or decode text using Unicode escape sequences.", - "longDescription": "This tool allows you to encode plain text into Unicode escape sequences (e.g., \\uXXXX) and decode them back. You can choose to output the hexadecimal digits in either uppercase or lowercase for encoding.", + "title": "Unicode Encoder / Decoder", "inputTitle": "Input", - "resultTitle": "Output", - "optionsTitle": "Options", + "resultTitle": "Processed Output", + "optionsTitle": "Mode", "caseOptionsTitle": "Case Options", - "encode": "Unicode Encode", - "decode": "Unicode Decode", + "encode": "Encode", + "decode": "Decode", "uppercase": "Uppercase Hex", + "description": "Convert text to Unicode escape sequences or decode them back to readable text.", + "shortDescription": "Encode or decode text using Unicode escape sequences.", "toolInfo": { - "title": "What is Unicode Escape?", - "description": "Unicode escape sequences are used to represent characters that may not be available in the character set of the document. They are written as '\\u' followed by four hexadecimal digits." + "title": "Unicode Encoder / Decoder", + "description": "This tool lets you convert plain text into Unicode escape sequences (e.g., \\uXXXX) and decode Unicode escape sequences back into standard text. You can also choose whether the hexadecimal output is formatted in uppercase or lowercase when encoding." } } } From b1f63a320779c3098fd535d3d967dfaeaeef4b99 Mon Sep 17 00:00:00 2001 From: Mike Bricknell-Barlow Date: Tue, 2 Dec 2025 12:52:52 +0000 Subject: [PATCH 013/164] Add tool to convert human time to decimal time --- public/locales/en/time.json | 6 ++ .../convert-time-to-decimal.service.test.ts | 40 +++++++++++ .../time/convert-time-to-decimal/index.tsx | 72 +++++++++++++++++++ .../time/convert-time-to-decimal/meta.ts | 15 ++++ .../time/convert-time-to-decimal/service.ts | 24 +++++++ .../time/convert-time-to-decimal/types.ts | 3 + src/pages/tools/time/index.ts | 4 +- 7 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/time/convert-time-to-decimal/convert-time-to-decimal.service.test.ts create mode 100644 src/pages/tools/time/convert-time-to-decimal/index.tsx create mode 100644 src/pages/tools/time/convert-time-to-decimal/meta.ts create mode 100644 src/pages/tools/time/convert-time-to-decimal/service.ts create mode 100644 src/pages/tools/time/convert-time-to-decimal/types.ts diff --git a/public/locales/en/time.json b/public/locales/en/time.json index 6bfb88e..9398b45 100644 --- a/public/locales/en/time.json +++ b/public/locales/en/time.json @@ -113,5 +113,11 @@ "zeroPaddingDescription": "Make all time components always be two digits wide.", "zeroPrintDescription": "Display the dropped parts as zero values \"00\".", "zeroPrintTruncatedParts": "Zero-print Truncated Parts" + }, + "convertTimeToDecimal": { + "title": "Convert time to decimal", + "description": "Convert formatted length of time (HH:MM:SS) to decimal format, e.g. 3.43 hours.", + "shortDescription": "Convert human time to decimal time", + "longDescription": "Convert formatted length of time (HH:MM:SS) to decimal format, e.g. 3.43 hours." } } diff --git a/src/pages/tools/time/convert-time-to-decimal/convert-time-to-decimal.service.test.ts b/src/pages/tools/time/convert-time-to-decimal/convert-time-to-decimal.service.test.ts new file mode 100644 index 0000000..9ca6839 --- /dev/null +++ b/src/pages/tools/time/convert-time-to-decimal/convert-time-to-decimal.service.test.ts @@ -0,0 +1,40 @@ +import { expect, describe, it } from 'vitest'; +import { convertTimeToDecimal } from './service'; + +describe('convert-time-to-decimal', () => { + it('should convert time to decimal with default decimal places', () => { + const input = '31:23:59'; + const result = convertTimeToDecimal(input, { decimalPlaces: '6' }); + expect(result).toBe('31.399722'); + }); + + it('should convert time to decimal with specified decimal places', () => { + const input = '31:23:59'; + const result = convertTimeToDecimal(input, { decimalPlaces: '10' }); + expect(result).toBe('31.3997222222'); + }); + + it('should convert time to decimal with supplied format of HH:MM:SS', () => { + const input = '13:25:30'; + const result = convertTimeToDecimal(input, { decimalPlaces: '6' }); + expect(result).toBe('13.425000'); + }); + + it('should convert time to decimal with supplied format of HH:MM', () => { + const input = '13:25'; + const result = convertTimeToDecimal(input, { decimalPlaces: '6' }); + expect(result).toBe('13.416667'); + }); + + it('should convert time to decimal with supplied format of HH:MM:', () => { + const input = '13:25'; + const result = convertTimeToDecimal(input, { decimalPlaces: '6' }); + expect(result).toBe('13.416667'); + }); + + it('should convert time to decimal with supplied format of HH.MM.SS', () => { + const input = '13.25.30'; + const result = convertTimeToDecimal(input, { decimalPlaces: '6' }); + expect(result).toBe('13.425000'); + }); +}); diff --git a/src/pages/tools/time/convert-time-to-decimal/index.tsx b/src/pages/tools/time/convert-time-to-decimal/index.tsx new file mode 100644 index 0000000..1e1c567 --- /dev/null +++ b/src/pages/tools/time/convert-time-to-decimal/index.tsx @@ -0,0 +1,72 @@ +import { Box } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import { convertTimeToDecimal } from './service'; +import { InitialValuesType } from './types'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; + +const initialValues: InitialValuesType = { + decimalPlaces: '6' +}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Convert time to decimal', + description: + 'This example shows how to convert a formatted time (HH:MM:SS) to a decimal version.', + sampleText: '31:23:59', + sampleResult: `31.399722`, + sampleOptions: { + decimalPlaces: '6' + } + } +]; +export default function ConvertTimeToDecimal({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (values: InitialValuesType, input: string) => { + setResult(convertTimeToDecimal(input, values)); + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'Decimal places', + component: ( + + updateField('decimalPlaces', val)} + type={'text'} + /> + + ) + } + ]; + return ( + } + resultComponent={} + initialValues={initialValues} + exampleCards={exampleCards} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/time/convert-time-to-decimal/meta.ts b/src/pages/tools/time/convert-time-to-decimal/meta.ts new file mode 100644 index 0000000..f7f8624 --- /dev/null +++ b/src/pages/tools/time/convert-time-to-decimal/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('time', { + i18n: { + name: 'time:convertTimeToDecimal.title', + description: 'time:convertTimeToDecimal.description', + shortDescription: 'time:convertTimeToDecimal.shortDescription', + longDescription: 'time:convertTimeToDecimal.longDescription' + }, + path: 'convert-time-to-decimal', + icon: 'material-symbols:schedule', + keywords: ['convert', 'time', 'to', 'decimal'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/time/convert-time-to-decimal/service.ts b/src/pages/tools/time/convert-time-to-decimal/service.ts new file mode 100644 index 0000000..f7b8dab --- /dev/null +++ b/src/pages/tools/time/convert-time-to-decimal/service.ts @@ -0,0 +1,24 @@ +import { InitialValuesType } from './types'; + +export function convertTimeToDecimal( + input: string, + options: InitialValuesType +): string { + if (!input || (!input.includes(':') && !input.includes('.'))) { + return ''; + } + + let splitTime = input.split(/[.:]/); + + let hours = parseInt(splitTime[0]); + let minutes = parseInt(splitTime[1]); + let seconds = splitTime[2] ? parseInt(splitTime[2]) : 0; + + let decimalTime = hours + minutes / 60; + + if (seconds !== 0) { + decimalTime += seconds / 3600; + } + + return decimalTime.toFixed(parseInt(options.decimalPlaces)).toString(); +} diff --git a/src/pages/tools/time/convert-time-to-decimal/types.ts b/src/pages/tools/time/convert-time-to-decimal/types.ts new file mode 100644 index 0000000..f4bf30a --- /dev/null +++ b/src/pages/tools/time/convert-time-to-decimal/types.ts @@ -0,0 +1,3 @@ +export type InitialValuesType = { + decimalPlaces: string; +}; diff --git a/src/pages/tools/time/index.ts b/src/pages/tools/time/index.ts index a98d27e..fbbcff4 100644 --- a/src/pages/tools/time/index.ts +++ b/src/pages/tools/time/index.ts @@ -1,3 +1,4 @@ +import { tool as timeConvertTimeToDecimal } from './convert-time-to-decimal/meta'; import { tool as timeConvertUnixToDate } from './convert-unix-to-date/meta'; import { tool as timeCrontabGuru } from './crontab-guru/meta'; import { tool as timeBetweenDates } from './time-between-dates/meta'; @@ -17,5 +18,6 @@ export const timeTools = [ timeBetweenDates, timeCrontabGuru, checkLeapYear, - timeConvertUnixToDate + timeConvertUnixToDate, + timeConvertTimeToDecimal ]; From 9d25c37f4bf9252fe8db4bdf943606b90f68806f Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Fri, 5 Dec 2025 17:59:23 +0100 Subject: [PATCH 014/164] chore: - time util file created - time validation util function implemented --- src/utils/time.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/utils/time.ts diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..e90782d --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,58 @@ +type TimeValidationResult = { + isValid: boolean; + hours: number; + minutes: number; + seconds: number; +}; + +/** + * Validates human-readable time format (HH:MM or HH:MM:SS) + * Supports either ':' or '.' as a separator, but not both + * @param {string} input - string time format + * * @returns {{ + * isValid: boolean, // true if the input is a valid time + * hours: number, // parsed hours (0 or greater) + * minutes: number, // parsed minutes (0-59) + * seconds: number // parsed seconds (0-59, 0 if not provided) + * }} + */ +export function humanTimeValidation(input: string): TimeValidationResult { + const result = { isValid: false, hours: 0, minutes: 0, seconds: 0 }; + + if (!input) return result; + + input = input.trim(); + + // Operator use validation + // use of one between these two operators '.' or ':' + + const hasColon = input.includes(':'); + const hasDot = input.includes('.'); + + if (hasColon && hasDot) return result; + + if (!hasColon && !hasDot) return result; + + const separator = hasColon ? ':' : '.'; + + // Time parts validation + + const parts = input.split(separator); + + if (parts.length < 2 || parts.length > 3) return result; + + const [h, m, s = '0'] = parts; + + // every character should be a digit + if (![h, m, s].every((x) => /^\d+$/.test(x))) return result; + + const hours = parseInt(h); + const minutes = parseInt(m); + const seconds = parseInt(s); + + if (minutes < 0 || minutes > 59) return result; + if (seconds < 0 || seconds > 59) return result; + if (hours < 0) return result; + + return { isValid: true, hours, minutes, seconds }; +} From e67612424d4c804545a78f063e30d4e60c13c489 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Fri, 5 Dec 2025 18:01:07 +0100 Subject: [PATCH 015/164] style: icon change --- src/pages/tools/time/convert-time-to-decimal/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/time/convert-time-to-decimal/meta.ts b/src/pages/tools/time/convert-time-to-decimal/meta.ts index f7f8624..889bb81 100644 --- a/src/pages/tools/time/convert-time-to-decimal/meta.ts +++ b/src/pages/tools/time/convert-time-to-decimal/meta.ts @@ -9,7 +9,7 @@ export const tool = defineTool('time', { longDescription: 'time:convertTimeToDecimal.longDescription' }, path: 'convert-time-to-decimal', - icon: 'material-symbols:schedule', + icon: 'material-symbols-light:decimal-increase-rounded', keywords: ['convert', 'time', 'to', 'decimal'], component: lazy(() => import('./index')) }); From 55354e3cd7fa7ec83419bfdbb973f9d4d10ead0e Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Fri, 5 Dec 2025 18:01:30 +0100 Subject: [PATCH 016/164] locales: enhanced tool description --- public/locales/en/time.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/time.json b/public/locales/en/time.json index 9398b45..1b12044 100644 --- a/public/locales/en/time.json +++ b/public/locales/en/time.json @@ -116,8 +116,8 @@ }, "convertTimeToDecimal": { "title": "Convert time to decimal", - "description": "Convert formatted length of time (HH:MM:SS) to decimal format, e.g. 3.43 hours.", + "description": "Convert a formatted time duration (HH:MM:SS) into a decimal hour value.", "shortDescription": "Convert human time to decimal time", - "longDescription": "Convert formatted length of time (HH:MM:SS) to decimal format, e.g. 3.43 hours." + "longDescription": "Convert a formatted time string (HH:MM:SS or HH:MM) into its decimal-hour equivalent. Hours can be any positive number, while minutes and seconds accept values from 0-59 and can be single or double digits (e.g., '12:5' or '12:05'). This function interprets hours, minutes, and seconds, then calculates the total duration as a single decimal value. It is useful for productivity tracking, payroll calculations, time-based billing, data analysis, or any workflow that requires converting human-readable time into a numerical format that can be easily summed, compared, or processed. For example, '03:26:00' becomes 3.43 hours, and '12:5' becomes 12.08 hours." } } From d9917c6b3e5ca87846c8765f22f31fc9f830b42d Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Fri, 5 Dec 2025 18:03:55 +0100 Subject: [PATCH 017/164] chore: - validations added for edge cases handling - Multiple lines processing added --- .../time/convert-time-to-decimal/service.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/pages/tools/time/convert-time-to-decimal/service.ts b/src/pages/tools/time/convert-time-to-decimal/service.ts index f7b8dab..770ae18 100644 --- a/src/pages/tools/time/convert-time-to-decimal/service.ts +++ b/src/pages/tools/time/convert-time-to-decimal/service.ts @@ -1,24 +1,37 @@ import { InitialValuesType } from './types'; +import { humanTimeValidation } from 'utils/time'; export function convertTimeToDecimal( input: string, options: InitialValuesType ): string { - if (!input || (!input.includes(':') && !input.includes('.'))) { - return ''; + if (!input) return ''; + + const dp = parseInt(options.decimalPlaces, 10); + if (isNaN(dp) || dp < 0) { + return 'Invalid decimal places value.'; } - let splitTime = input.split(/[.:]/); + // Multiple lines processing + const lines = input.split('\n'); + if (!lines) return ''; - let hours = parseInt(splitTime[0]); - let minutes = parseInt(splitTime[1]); - let seconds = splitTime[2] ? parseInt(splitTime[2]) : 0; + const result: string[] = []; - let decimalTime = hours + minutes / 60; + lines.forEach((line) => { + line = line.trim(); + if (!line) return; - if (seconds !== 0) { - decimalTime += seconds / 3600; - } + const { isValid, hours, minutes, seconds } = humanTimeValidation(line); - return decimalTime.toFixed(parseInt(options.decimalPlaces)).toString(); + if (!isValid) { + result.push('Incorrect input format use `HH:MM:(SS)` or `HH.MM.(SS )`.'); + return; + } + + const decimalTime = hours + minutes / 60 + seconds / 3600; + result.push(decimalTime.toFixed(dp).toString()); + }); + + return result.join('\n'); } From 7dde9a1e3a0e6fc763e285dac57d04213f2a4105 Mon Sep 17 00:00:00 2001 From: Nello-Moreira Date: Fri, 5 Dec 2025 19:51:25 -0400 Subject: [PATCH 018/164] Fix pdf plural --- public/locales/de/pdf.json | 8 ++++---- public/locales/en/pdf.json | 8 ++++---- public/locales/es/pdf.json | 8 ++++---- public/locales/fr/pdf.json | 8 ++++---- public/locales/hi/pdf.json | 8 ++++---- public/locales/ja/pdf.json | 8 ++++---- public/locales/nl/pdf.json | 8 ++++---- public/locales/pt/pdf.json | 8 ++++---- public/locales/ru/pdf.json | 8 ++++---- public/locales/zh/pdf.json | 8 ++++---- src/pages/tools/pdf/rotate-pdf/index.tsx | 10 ++++++++-- src/pages/tools/pdf/split-pdf/index.tsx | 12 ++++++++++-- 12 files changed, 58 insertions(+), 44 deletions(-) diff --git a/public/locales/de/pdf.json b/public/locales/de/pdf.json index e811d7b..87f0995 100644 --- a/public/locales/de/pdf.json +++ b/public/locales/de/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Ändern Sie die Ausrichtung von PDF-Seiten, indem Sie sie um 90, 180 oder 270 Grad drehen. Nützlich zum Korrigieren falsch gescannter Dokumente oder zum Vorbereiten von PDFs für den Druck.", "pageRangesDescription": "Geben Sie Seitenzahlen oder Bereiche durch Kommas getrennt ein (z. B. 1,3,5-7).", "pageRangesPlaceholder": "z.B. 1,5-8", - "pagesWillBeRotated": "{{count}} Seite{{count !== 1 ? 's' : ''}} wird gedreht", - "pdfPageCount": "PDF hat {{count}} Seite{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} Seite{{plural}} wird gedreht", + "pdfPageCount": "PDF hat {{count}} Seite{{plural}}", "resultTitle": "Gedrehtes PDF", "rotatingPages": "Seiten drehen", "rotationAngle": "Drehwinkel", @@ -103,11 +103,11 @@ "description": "Extrahieren Sie bestimmte Seiten aus einem PDF-Dokument.", "extractingPages": "Seiten extrahieren", "inputTitle": "Eingabe-PDF", - "pageExtractionPreview": "{{count}} Seite{{count !== 1 ? 's' : ''}} wird extrahiert", + "pageExtractionPreview": "{{count}} Seite{{plural}} wird extrahiert", "pageRangesDescription": "Geben Sie Seitenzahlen oder Bereiche durch Kommas getrennt ein (z. B. 1,3,5-7).", "pageRangesPlaceholder": "z.B. 1,5-8", "pageSelection": "Seitenauswahl", - "pdfPageCount": "PDF hat {{count}} Seite{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF hat {{count}} Seite{{plural}}", "resultTitle": "Extrahiertes PDF", "shortDescription": "Extrahieren Sie bestimmte Seiten aus einer PDF-Datei", "title": "PDF teilen", diff --git a/public/locales/en/pdf.json b/public/locales/en/pdf.json index 82e5309..d48a764 100644 --- a/public/locales/en/pdf.json +++ b/public/locales/en/pdf.json @@ -86,8 +86,8 @@ "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.", "pageRangesDescription": "Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)", "pageRangesPlaceholder": "e.g., 1,5-8", - "pagesWillBeRotated": "{{count}} page{{count !== 1 ? 's' : ''}} will be rotated", - "pdfPageCount": "PDF has {{count}} page{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} page{{plural}} will be rotated", + "pdfPageCount": "PDF has {{count}} page{{plural}}", "resultTitle": "Rotated PDF", "rotatingPages": "Rotating pages", "rotationAngle": "Rotation Angle", @@ -103,11 +103,11 @@ "description": "Extract specific pages from a PDF document.", "extractingPages": "Extracting pages", "inputTitle": "Input PDF", - "pageExtractionPreview": "{{count}} page{{count !== 1 ? 's' : ''}} will be extracted", + "pageExtractionPreview": "{{count}} page{{ plural }} will be extracted", "pageRangesDescription": "Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)", "pageRangesPlaceholder": "e.g., 1,5-8", "pageSelection": "Page Selection", - "pdfPageCount": "PDF has {{count}} page{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF has {{count}} page{{plural}}", "resultTitle": "Extracted PDF", "shortDescription": "Extract specific pages from a PDF file", "title": "Split PDF", diff --git a/public/locales/es/pdf.json b/public/locales/es/pdf.json index 083fd01..d458d77 100644 --- a/public/locales/es/pdf.json +++ b/public/locales/es/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Cambie la orientación de las páginas PDF girándolas 90, 180 o 270 grados. Útil para corregir documentos escaneados incorrectamente o preparar archivos PDF para imprimir.", "pageRangesDescription": "Introduzca números de página o rangos separados por comas (por ejemplo, 1, 3, 5-7)", "pageRangesPlaceholder": "p. ej., 1,5-8", - "pagesWillBeRotated": "{{count}} página{{count !== 1 ? 's' : ''}} se rotará", - "pdfPageCount": "PDF tiene {{count}} página{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} página{{plural}} se rotará", + "pdfPageCount": "PDF tiene {{count}} página{{plural}}", "resultTitle": "PDF rotado", "rotatingPages": "Páginas rotatorias", "rotationAngle": "Ángulo de rotación", @@ -103,11 +103,11 @@ "description": "Extraer páginas específicas de un documento PDF.", "extractingPages": "Extrayendo páginas", "inputTitle": "PDF de entrada", - "pageExtractionPreview": "{{count}} página{{count !== 1 ? 's' : ''}} se extraerán", + "pageExtractionPreview": "{{count}} página{{ plural }} se extraerán", "pageRangesDescription": "Introduzca números de página o rangos separados por comas (por ejemplo, 1, 3, 5-7)", "pageRangesPlaceholder": "p. ej., 1,5-8", "pageSelection": "Selección de página", - "pdfPageCount": "PDF tiene {{count}} página{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF tiene {{count}} página{{plural}}", "resultTitle": "PDF extraído", "shortDescription": "Extraer páginas específicas de un archivo PDF", "title": "PDF dividido", diff --git a/public/locales/fr/pdf.json b/public/locales/fr/pdf.json index fac70af..89c44b0 100644 --- a/public/locales/fr/pdf.json +++ b/public/locales/fr/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Modifiez l'orientation des pages PDF en les faisant pivoter de 90, 180 ou 270 degrés. Utile pour corriger des documents mal numérisés ou préparer des PDF pour l'impression.", "pageRangesDescription": "Saisissez les numéros de page ou les plages séparés par des virgules (par exemple, 1, 3, 5-7)", "pageRangesPlaceholder": "par exemple, 1,5-8", - "pagesWillBeRotated": "{{count}} page{{count !== 1 ? 's' : ''}} sera tourné", - "pdfPageCount": "PDF a {{count}} page{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} page{{plural}} sera tourné", + "pdfPageCount": "PDF a {{count}} page{{plural}}", "resultTitle": "PDF pivoté", "rotatingPages": "Rotation des pages", "rotationAngle": "Angle de rotation", @@ -103,11 +103,11 @@ "description": "Extraire des pages spécifiques d'un document PDF.", "extractingPages": "Extraire les pages", "inputTitle": "Entrée PDF", - "pageExtractionPreview": "{{count}} page{{count !== 1 ? 's' : ''}} sera(ont) extrait(s)", + "pageExtractionPreview": "{{count}} page{{plural}} sera(ont) extrait(s)", "pageRangesDescription": "Saisissez les numéros de page ou les plages séparés par des virgules (par exemple, 1, 3, 5-7)", "pageRangesPlaceholder": "par exemple, 1,5-8", "pageSelection": "Sélection de page", - "pdfPageCount": "PDF a {{count}} page{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF a {{count}} page{{plural}}", "resultTitle": "PDF extrait", "shortDescription": "Extraire des pages spécifiques d'un fichier PDF", "title": "Diviser le PDF", diff --git a/public/locales/hi/pdf.json b/public/locales/hi/pdf.json index fdd808c..f5bdba9 100644 --- a/public/locales/hi/pdf.json +++ b/public/locales/hi/pdf.json @@ -122,8 +122,8 @@ "pageRangesDescription": "पृष्ठ संख्या या श्रेणियाँ अल्पविराम से अलग करके दर्ज करें (उदाहरण के लिए, 1,3,5-7)", "pageRangesPlaceholder": "उदाहरणार्थ, 1,5-8", "pagesPlaceholder": "जैसे 1,3-5,7", - "pagesWillBeRotated": "{{count}} पृष्ठ{{count !== 1 ? 's' : ''}} घुमाया जाएगा", - "pdfPageCount": "पीडीएफ में है {{count}} पृष्ठ{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} पृष्ठ{{plural}} घुमाया जाएगा", + "pdfPageCount": "पीडीएफ में है {{count}} पृष्ठ{{plural}}", "resultTitle": "घुमाया गया PDF", "rotatingPages": "घूमते हुए पृष्ठ", "rotationAngle": "घुमाने का कोण", @@ -142,12 +142,12 @@ "extractingPages": "पृष्ठ निकालना", "includeBookmarks": "बुकमार्क शामिल करें", "inputTitle": "इनपुट PDF", - "pageExtractionPreview": "{{count}} पृष्ठ{{count !== 1 ? 's' : ''}} निकाला जाएगा", + "pageExtractionPreview": "{{count}} पृष्ठ{{ plural }} निकाला जाएगा", "pageRanges": "पेज श्रेणियां", "pageRangesDescription": "पृष्ठ संख्या या श्रेणियाँ अल्पविराम से अलग करके दर्ज करें (उदाहरण के लिए, 1,3,5-7)", "pageRangesPlaceholder": "उदाहरणार्थ, 1,5-8", "pageSelection": "पृष्ठ चयन", - "pdfPageCount": "पीडीएफ में है {{count}} पृष्ठ{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "पीडीएफ में है {{count}} पृष्ठ{{plural}}", "rangesPlaceholder": "जैसे 1,3-5,7", "resultTitle": "विभाजित PDF फ़ाइलें", "shortDescription": "पीडीएफ फ़ाइल से विशिष्ट पेज निकालें", diff --git a/public/locales/ja/pdf.json b/public/locales/ja/pdf.json index e421665..1ebbd58 100644 --- a/public/locales/ja/pdf.json +++ b/public/locales/ja/pdf.json @@ -86,8 +86,8 @@ "longDescription": "PDFページの向きを90度、180度、または270度回転して変更できます。スキャンミスを修正したり、印刷用のPDFを準備したりする際に便利です。", "pageRangesDescription": "ページ番号または範囲をコンマで区切って入力してください(例:1,3,5-7)", "pageRangesPlaceholder": "例:1.5-8", - "pagesWillBeRotated": "{{count}} ページ{{count !== 1 ? 's' : ''}} 回転します", - "pdfPageCount": "PDFには {{count}} ページ{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} ページ{{plural}} 回転します", + "pdfPageCount": "PDFには {{count}} ページ{{plural}}", "resultTitle": "回転したPDF", "rotatingPages": "ページの回転", "rotationAngle": "回転角度", @@ -103,11 +103,11 @@ "description": "PDF ドキュメントから特定のページを抽出します。", "extractingPages": "ページの抽出", "inputTitle": "入力PDF", - "pageExtractionPreview": "{{count}} ページ{{count !== 1 ? 's' : ''}} 抽出されます", + "pageExtractionPreview": "{{count}} ページ{{ plural }} 抽出されます", "pageRangesDescription": "ページ番号または範囲をコンマで区切って入力してください(例:1,3,5-7)", "pageRangesPlaceholder": "例:1.5-8", "pageSelection": "ページ選択", - "pdfPageCount": "PDFには {{count}} ページ{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDFには {{count}} ページ{{plural}}", "resultTitle": "抽出されたPDF", "shortDescription": "PDFファイルから特定のページを抽出する", "title": "PDFを分割", diff --git a/public/locales/nl/pdf.json b/public/locales/nl/pdf.json index 2d8d020..9b0fc09 100644 --- a/public/locales/nl/pdf.json +++ b/public/locales/nl/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Wijzig de oriëntatie van PDF-pagina's door ze 90, 180 of 270 graden te draaien. Handig voor het corrigeren van onjuist gescande documenten of het voorbereiden van PDF's voor afdrukken.", "pageRangesDescription": "Voer paginanummers of bereiken in, gescheiden door komma's (bijv. 1, 3, 5-7)", "pageRangesPlaceholder": "bijv. 1,5-8", - "pagesWillBeRotated": "{{count}} pagina{{count !== 1 ? 's' : ''}} zal worden gedraaid", - "pdfPageCount": "PDF heeft {{count}} pagina{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} pagina{{plural}} zal worden gedraaid", + "pdfPageCount": "PDF heeft {{count}} pagina{{plural}}", "resultTitle": "Gedraaide PDF", "rotatingPages": "Roterende pagina's", "rotationAngle": "Rotatiehoek", @@ -103,11 +103,11 @@ "description": "Specifieke pagina's uit een PDF-document extraheren.", "extractingPages": "Pagina's extraheren", "inputTitle": "PDF-invoer", - "pageExtractionPreview": "{{count}} pagina{{count !== 1 ? 's' : ''}} zal worden geëxtraheerd", + "pageExtractionPreview": "{{count}} pagina{{ plural }} zal worden geëxtraheerd", "pageRangesDescription": "Voer paginanummers of bereiken in, gescheiden door komma's (bijv. 1, 3, 5-7)", "pageRangesPlaceholder": "bijv. 1,5-8", "pageSelection": "Paginaselectie", - "pdfPageCount": "PDF heeft {{count}} pagina{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF heeft {{count}} pagina{{plural}}", "resultTitle": "Geëxtraheerde PDF", "shortDescription": "Specifieke pagina's uit een PDF-bestand extraheren", "title": "PDF splitsen", diff --git a/public/locales/pt/pdf.json b/public/locales/pt/pdf.json index f1a6a80..36770f6 100644 --- a/public/locales/pt/pdf.json +++ b/public/locales/pt/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Altere a orientação das páginas do PDF girando-as 90, 180 ou 270 graus. Útil para corrigir documentos digitalizados incorretamente ou preparar PDFs para impressão.", "pageRangesDescription": "Insira números de página ou intervalos separados por vírgulas (por exemplo, 1,3,5-7)", "pageRangesPlaceholder": "por exemplo, 1,5-8", - "pagesWillBeRotated": "{{count}} página{{count !== 1 ? 's' : ''}} será girado", - "pdfPageCount": "PDF tem {{count}} página{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} página{{plural}} será girado", + "pdfPageCount": "PDF tem {{count}} página{{plural}}", "resultTitle": "PDF girado", "rotatingPages": "Páginas rotativas", "rotationAngle": "Ângulo de rotação", @@ -103,11 +103,11 @@ "description": "Extraia páginas específicas de um documento PDF.", "extractingPages": "Extraindo páginas", "inputTitle": "PDF de entrada", - "pageExtractionPreview": "{{count}} página{{count !== 1 ? 's' : ''}} será extraído", + "pageExtractionPreview": "{{count}} página{{ plural }} será extraído", "pageRangesDescription": "Insira números de página ou intervalos separados por vírgulas (por exemplo, 1,3,5-7)", "pageRangesPlaceholder": "por exemplo, 1,5-8", "pageSelection": "Seleção de página", - "pdfPageCount": "PDF tem {{count}} página{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF tem {{count}} página{{plural}}", "resultTitle": "PDF extraído", "shortDescription": "Extrair páginas específicas de um arquivo PDF", "title": "Dividir PDF", diff --git a/public/locales/ru/pdf.json b/public/locales/ru/pdf.json index 547cfdb..f7079c7 100644 --- a/public/locales/ru/pdf.json +++ b/public/locales/ru/pdf.json @@ -86,8 +86,8 @@ "longDescription": "Измените ориентацию страниц PDF-файла, повернув их на 90, 180 или 270 градусов. Это полезно для исправления неправильно отсканированных документов или подготовки PDF-файлов к печати.", "pageRangesDescription": "Введите номера страниц или диапазоны, разделенные запятыми (например, 1,3,5-7)", "pageRangesPlaceholder": "например, 1,5-8", - "pagesWillBeRotated": "{{count}} страница{{count !== 1 ? 's' : ''}} будет вращаться", - "pdfPageCount": "PDF имеет {{count}} страница{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} страница{{plural}} будет вращаться", + "pdfPageCount": "PDF имеет {{count}} страница{{plural}}", "resultTitle": "Повернутый PDF-файл", "rotatingPages": "Ротация страниц", "rotationAngle": "Угол поворота", @@ -103,11 +103,11 @@ "description": "Извлечение определенных страниц из PDF-документа.", "extractingPages": "Извлечение страниц", "inputTitle": "Входной PDF-файл", - "pageExtractionPreview": "{{count}} страница{{count !== 1 ? 's' : ''}} будут извлечены", + "pageExtractionPreview": "{{count}} страница{{ plural }} будут извлечены", "pageRangesDescription": "Введите номера страниц или диапазоны, разделенные запятыми (например, 1,3,5-7)", "pageRangesPlaceholder": "например, 1,5-8", "pageSelection": "Выбор страницы", - "pdfPageCount": "PDF имеет {{count}} страница{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF имеет {{count}} страница{{plural}}", "resultTitle": "Извлеченный PDF-файл", "shortDescription": "Извлечь определенные страницы из PDF-файла", "title": "Разделить PDF", diff --git a/public/locales/zh/pdf.json b/public/locales/zh/pdf.json index 76c8c98..22bcce4 100644 --- a/public/locales/zh/pdf.json +++ b/public/locales/zh/pdf.json @@ -86,8 +86,8 @@ "longDescription": "通过将 PDF 页面旋转 90 度、180 度或 270 度来更改其方向。这对于修复扫描错误的文档或准备打印 PDF 非常有用。", "pageRangesDescription": "输入页码或范围,以逗号分隔(例如,1,3,5-7)", "pageRangesPlaceholder": "例如,1.5-8", - "pagesWillBeRotated": "{{count}} 页{{count !== 1 ? 's' : ''}} 将会轮换", - "pdfPageCount": "PDF 有 {{count}} 页{{count !== 1 ? 's' : ''}}", + "pagesWillBeRotated": "{{count}} 页{{plural}} 将会轮换", + "pdfPageCount": "PDF 有 {{count}} 页{{plural}}", "resultTitle": "旋转的 PDF", "rotatingPages": "旋转页面", "rotationAngle": "旋转角度", @@ -103,11 +103,11 @@ "description": "从 PDF 文档中提取特定页面。", "extractingPages": "提取页面", "inputTitle": "输入 PDF", - "pageExtractionPreview": "{{count}} 页{{count !== 1 ? 's' : ''}} 将被提取", + "pageExtractionPreview": "{{count}} 页{{ plural }} 将被提取", "pageRangesDescription": "输入页码或范围,以逗号分隔(例如,1,3,5-7)", "pageRangesPlaceholder": "例如,1.5-8", "pageSelection": "页面选择", - "pdfPageCount": "PDF 有 {{count}} 页{{count !== 1 ? 's' : ''}}", + "pdfPageCount": "PDF 有 {{count}} 页{{plural}}", "resultTitle": "提取的 PDF", "shortDescription": "从 PDF 文件中提取特定页面", "title": "拆分 PDF", diff --git a/src/pages/tools/pdf/rotate-pdf/index.tsx b/src/pages/tools/pdf/rotate-pdf/index.tsx index a262805..397c1c2 100644 --- a/src/pages/tools/pdf/rotate-pdf/index.tsx +++ b/src/pages/tools/pdf/rotate-pdf/index.tsx @@ -92,7 +92,10 @@ export default function RotatePdf({ if (applyToAllPages) { setPageRangePreview( totalPages > 0 - ? t('rotatePdf.allPagesWillBeRotated', { count: totalPages }) + ? t('rotatePdf.allPagesWillBeRotated', { + count: totalPages, + plural: totalPages === 1 ? '' : 's' + }) : '' ); return; @@ -191,7 +194,10 @@ export default function RotatePdf({ {totalPages > 0 && ( - {t('rotatePdf.pdfPageCount', { count: totalPages })} + {t('rotatePdf.pdfPageCount', { + count: totalPages, + plural: totalPages === 1 ? '' : 's' + })} )} {totalPages > 0 && ( - {t('splitPdf.pdfPageCount', { count: totalPages })} + {t('splitPdf.pdfPageCount', { + count: totalPages, + plural: totalPages === 1 ? '' : 's' + })} )} Date: Wed, 10 Dec 2025 16:43:04 +0100 Subject: [PATCH 019/164] Chore: - 'file' util added - getFileExtension method --- src/utils/file.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/utils/file.ts diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..a3200ac --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,11 @@ +/** + * Returns the file extension + * + * @param {string} filename - The filename + * @return {string} - the file extension + */ +export function getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.'); + if (lastDot <= 0) return ''; // No extension + return filename.slice(lastDot + 1).toLowerCase(); +} From cfc0ba3b418469bfde073e42d07438db2e54a2a8 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 16:45:10 +0100 Subject: [PATCH 020/164] chore: - 'types.ts' file - global variables including necessary details about audio formats added --- .../tools/converters/audio-converter/types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/pages/tools/converters/audio-converter/types.ts diff --git a/src/pages/tools/converters/audio-converter/types.ts b/src/pages/tools/converters/audio-converter/types.ts new file mode 100644 index 0000000..d749b7a --- /dev/null +++ b/src/pages/tools/converters/audio-converter/types.ts @@ -0,0 +1,16 @@ +export const AUDIO_FORMATS = { + // Lossy formats + mp3: { codec: 'libmp3lame', bitrate: '192k', mimeType: 'audio/mpeg' }, + aac: { codec: 'aac', bitrate: '192k', mimeType: 'audio/aac' }, + ogg: { codec: 'libvorbis', bitrate: '192k', mimeType: 'audio/ogg' }, + + // Lossless formats + wav: { codec: 'pcm_s16le', bitrate: null, mimeType: 'audio/wav' }, + flac: { codec: 'flac', bitrate: null, mimeType: 'audio/flac' } +} as const; + +export type AudioFormat = keyof typeof AUDIO_FORMATS; + +export type InitialValuesType = { + outputFormat: AudioFormat; +}; From 09456bf6eda3356e3da4b52e1ee59a9cc4bf4c40 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 16:47:51 +0100 Subject: [PATCH 021/164] chore: - use of InitialValuesType - cleaning up added after conversion --- .../converters/audio-converter/service.ts | 92 +++++++------------ 1 file changed, 34 insertions(+), 58 deletions(-) diff --git a/src/pages/tools/converters/audio-converter/service.ts b/src/pages/tools/converters/audio-converter/service.ts index e2676c3..aaac23e 100644 --- a/src/pages/tools/converters/audio-converter/service.ts +++ b/src/pages/tools/converters/audio-converter/service.ts @@ -1,6 +1,12 @@ import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; +import { InitialValuesType, AUDIO_FORMATS } from './types'; +import { getFileExtension } from 'utils/file'; +/** + * optimzed call for FFmpeg instance creation, + * avoiding to download required FFmpeg binaries on every reload + */ const ffmpeg = new FFmpeg(); let isLoaded = false; @@ -21,80 +27,50 @@ async function loadFFmpeg() { */ export async function convertAudio( input: File, - outputFormat: 'mp3' | 'aac' | 'wav' + options: InitialValuesType ): Promise { await loadFFmpeg(); // Use the original input extension for input filename - const inputExtMatch = input.name.match(/\.[^.]+$/); - const inputExt = inputExtMatch ? inputExtMatch[0] : '.audio'; + const inputExt = getFileExtension(input.name); - const inputFileName = `input${inputExt}`; - const outputFileName = `output.${outputFormat}`; + if (inputExt === options.outputFormat) return input; + + const inputFileName = inputExt ? `input.${inputExt}` : 'input'; + const outputFileName = `output.${options.outputFormat}`; // Write the input file to FFmpeg FS await ffmpeg.writeFile(inputFileName, await fetchFile(input)); // Build the FFmpeg args depending on the output format // You can customize the codec and bitrate options per format here - let args: string[]; - switch (outputFormat) { - case 'mp3': - args = [ - '-i', - inputFileName, - '-c:a', - 'libmp3lame', - '-b:a', - '192k', - outputFileName - ]; - break; + const format = AUDIO_FORMATS[options.outputFormat]; + const { codec, bitrate, mimeType } = format; - case 'aac': - args = [ - '-i', - inputFileName, - '-c:a', - 'aac', - '-b:a', - '192k', - outputFileName - ]; - break; - - case 'wav': - args = ['-i', inputFileName, '-c:a', 'pcm_s16le', outputFileName]; - break; - - default: - throw new Error(`Unsupported output format: ${outputFormat}`); - } + const args = bitrate + ? ['-i', inputFileName, '-c:a', codec, '-b:a', bitrate, outputFileName] + : ['-i', inputFileName, '-c:a', codec, outputFileName]; // Execute ffmpeg with arguments - await ffmpeg.exec(args); + try { + await ffmpeg.exec(args); - // Read the output file from FFmpeg FS - const data = await ffmpeg.readFile(outputFileName); + // Read the output file from FFmpeg FS + const data = await ffmpeg.readFile(outputFileName); - // Determine MIME type by outputFormat - let mimeType = ''; - switch (outputFormat) { - case 'mp3': - mimeType = 'audio/mpeg'; - break; - case 'aac': - mimeType = 'audio/aac'; - break; - case 'wav': - mimeType = 'audio/wav'; - break; + // Create a new File with the original name but new extension + const baseName = input.name.replace(/\.[^.]+$/, ''); + const convertedFileName = `${baseName}.${options.outputFormat}`; + + return new File([data], convertedFileName, { type: mimeType }); + } finally { + // Clean up FFmpeg virtual filesystem + try { + await ffmpeg.deleteFile(inputFileName); + await ffmpeg.deleteFile(outputFileName); + } catch (e) { + // Ignore cleanup errors + } } - - // Create a new File with the original name but new extension - const baseName = input.name.replace(/\.[^.]+$/, ''); - const convertedFileName = `${baseName}.${outputFormat}`; - - return new File([data], convertedFileName, { type: mimeType }); } From ff27f8debe251cd89d961c151f403fb40707a937 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 16:50:18 +0100 Subject: [PATCH 022/164] chore: select values populated through AUDIO_FORMATS iteration --- .../converters/audio-converter/index.tsx | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/pages/tools/converters/audio-converter/index.tsx b/src/pages/tools/converters/audio-converter/index.tsx index 10f55a5..1654bf7 100644 --- a/src/pages/tools/converters/audio-converter/index.tsx +++ b/src/pages/tools/converters/audio-converter/index.tsx @@ -8,10 +8,7 @@ import SelectWithDesc from '@components/options/SelectWithDesc'; import { convertAudio } from './service'; import { useTranslation } from 'react-i18next'; import { GetGroupsType } from '@components/options/ToolOptions'; - -type InitialValuesType = { - outputFormat: 'mp3' | 'aac' | 'wav'; -}; +import { InitialValuesType, AUDIO_FORMATS, AudioFormat } from './types'; const initialValues: InitialValuesType = { outputFormat: 'mp3' @@ -26,6 +23,24 @@ export default function AudioConverter({ const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); + const compute = async ( + values: InitialValuesType, + inputFile: File | null + ): Promise => { + if (!inputFile) return; + + try { + setLoading(true); + const resultFile = await convertAudio(inputFile, values); + setResult(resultFile); + } catch (error) { + console.error('Conversion failed:', error); + setResult(null); + } finally { + setLoading(false); + } + }; + // Explicitly type getGroups to match GetGroupsType const getGroups: GetGroupsType = ({ values, @@ -42,17 +57,11 @@ export default function AudioConverter({ - updateField( - 'outputFormat', - value as InitialValuesType['outputFormat'] - ) - } - options={[ - { label: 'MP3', value: 'mp3' }, - { label: 'AAC', value: 'aac' }, - { label: 'WAV', value: 'wav' } - ]} + onChange={(value) => updateField('outputFormat', value)} + options={Object.entries(AUDIO_FORMATS).map(([value]) => ({ + label: value.toUpperCase(), + value: value as AudioFormat + }))} description={t( 'audioConverter.outputFormatDescription', 'Select the desired output audio format' @@ -63,24 +72,6 @@ export default function AudioConverter({ } ]; - const compute = async ( - values: InitialValuesType, - inputFile: File | null - ): Promise => { - if (!inputFile) return; - - try { - setLoading(true); - const resultFile = await convertAudio(inputFile, values.outputFormat); - setResult(resultFile); - } catch (error) { - console.error('Conversion failed:', error); - setResult(null); - } finally { - setLoading(false); - } - }; - return ( Date: Wed, 10 Dec 2025 17:01:26 +0100 Subject: [PATCH 023/164] tests: convert audio test cases --- .../audio-converter.service.test.ts | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts index ae4ae6e..9c23299 100644 --- a/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts +++ b/src/pages/tools/converters/audio-converter/audio-converter.service.test.ts @@ -1,4 +1,6 @@ import { expect, describe, it, vi, beforeEach } from 'vitest'; +import { convertAudio } from './service'; +import { InitialValuesType } from './types'; // Mock FFmpeg since it doesn't support Node.js in tests vi.mock('@ffmpeg/ffmpeg', () => ({ @@ -16,8 +18,6 @@ vi.mock('@ffmpeg/util', () => ({ fetchFile: vi.fn().mockResolvedValue(new Uint8Array([10, 20, 30, 40, 50])) })); -import { convertAudio } from './service'; - describe('convertAudio', () => { let mockInputFile: File; @@ -29,17 +29,17 @@ describe('convertAudio', () => { }); it('should convert to MP3 format correctly', async () => { - const outputFormat = 'mp3' as const; - const result = await convertAudio(mockInputFile, outputFormat); + const options: InitialValuesType = { outputFormat: 'mp3' }; + const result = await convertAudio(mockInputFile, options); expect(result).toBeInstanceOf(File); - expect(result.name).toBe('input.mp3'); // base name + outputFormat extension + expect(result.name).toBe('input.mp3'); expect(result.type).toBe('audio/mpeg'); }); it('should convert to AAC format correctly', async () => { - const outputFormat = 'aac' as const; - const result = await convertAudio(mockInputFile, outputFormat); + const options: InitialValuesType = { outputFormat: 'aac' }; + const result = await convertAudio(mockInputFile, options); expect(result).toBeInstanceOf(File); expect(result.name).toBe('input.aac'); @@ -47,18 +47,49 @@ describe('convertAudio', () => { }); it('should convert to WAV format correctly', async () => { - const outputFormat = 'wav' as const; - const result = await convertAudio(mockInputFile, outputFormat); + const options: InitialValuesType = { outputFormat: 'wav' }; + const result = await convertAudio(mockInputFile, options); expect(result).toBeInstanceOf(File); expect(result.name).toBe('input.wav'); expect(result.type).toBe('audio/wav'); }); - it('should throw error for unsupported formats', async () => { - // @ts-expect-error - intentionally passing unsupported format - await expect(convertAudio(mockInputFile, 'flac')).rejects.toThrow( - 'Unsupported output format' - ); + it('should convert to FLAC format correctly', async () => { + const options: InitialValuesType = { outputFormat: 'flac' }; + const result = await convertAudio(mockInputFile, options); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('input.flac'); + expect(result.type).toBe('audio/flac'); + }); + + it('should convert to OGG format correctly', async () => { + const options: InitialValuesType = { outputFormat: 'ogg' }; + const result = await convertAudio(mockInputFile, options); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('input.ogg'); + expect(result.type).toBe('audio/ogg'); + }); + + it('should return original file if input format matches output format', async () => { + const options: InitialValuesType = { outputFormat: 'aac' }; + const result = await convertAudio(mockInputFile, options); + + expect(result).toBe(mockInputFile); // Same instance + expect(result.name).toBe('input.aac'); + }); + + it('should handle files without extensions', async () => { + const fileNoExt = new File([new Uint8Array([1, 2, 3])], 'audiofile', { + type: 'audio/aac' + }); + const options: InitialValuesType = { outputFormat: 'mp3' }; + const result = await convertAudio(fileNoExt, options); + + expect(result).toBeInstanceOf(File); + expect(result.name).toBe('audiofile.mp3'); + expect(result.type).toBe('audio/mpeg'); }); }); From 29852a44dd85719b00f63a3c519aecd9d2095f25 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 17:19:02 +0100 Subject: [PATCH 024/164] chore: ToolAudioInput accepted types changed to supported conversion formats --- src/components/input/ToolAudioInput.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/input/ToolAudioInput.tsx b/src/components/input/ToolAudioInput.tsx index c9090a7..143973c 100644 --- a/src/components/input/ToolAudioInput.tsx +++ b/src/components/input/ToolAudioInput.tsx @@ -2,13 +2,19 @@ import React, { useRef } from 'react'; import { Box, Typography } from '@mui/material'; import BaseFileInput from './BaseFileInput'; import { BaseFileInputProps } from './file-input-utils'; +import { AUDIO_FORMATS } from 'pages/tools/converters/audio-converter/types'; interface AudioFileInputProps extends Omit { accept?: string[]; } +const AUDIO_ACCEPT_TYPES = [ + 'audio/*', + ...Object.keys(AUDIO_FORMATS).map((format) => `.${format}`) +]; + export default function ToolAudioInput({ - accept = ['audio/*', '.mp3', '.wav', '.aac'], + accept = AUDIO_ACCEPT_TYPES, ...props }: AudioFileInputProps) { const audioRef = useRef(null); From 29a174bc2218cc737fa21e2dbf88e3179560ce7c Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 18:16:20 +0100 Subject: [PATCH 025/164] fix: data wrapped inside blob --- src/pages/tools/converters/audio-converter/service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/tools/converters/audio-converter/service.ts b/src/pages/tools/converters/audio-converter/service.ts index aaac23e..dc243e3 100644 --- a/src/pages/tools/converters/audio-converter/service.ts +++ b/src/pages/tools/converters/audio-converter/service.ts @@ -63,7 +63,13 @@ export async function convertAudio( const baseName = input.name.replace(/\.[^.]+$/, ''); const convertedFileName = `${baseName}.${options.outputFormat}`; - return new File([data], convertedFileName, { type: mimeType }); + return new File( + [new Blob([data as any], { type: mimeType })], + convertedFileName, + { + type: mimeType + } + ); } finally { // Clean up FFmpeg virtual filesystem try { From 13fee3aefa3a2cd3a350144c7c1b72638b6ded50 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Wed, 10 Dec 2025 18:17:34 +0100 Subject: [PATCH 026/164] chore: - convertes type specified (generalUsers) - category icon changed --- src/tools/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tools/index.ts b/src/tools/index.ts index 481e073..1dbeb2f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -140,7 +140,7 @@ const categoriesConfig: { }, { type: 'converters', - icon: 'mdi:swap-horizontal', + icon: 'streamline-plump:convert-pdf-1', value: 'translation:categories.converters.description', title: 'translation:categories.converters.title' } @@ -154,7 +154,8 @@ const CATEGORIES_USER_TYPES_MAPPINGS: Partial> = png: 'generalUsers', 'image-generic': 'generalUsers', video: 'generalUsers', - audio: 'generalUsers' + audio: 'generalUsers', + converters: 'generalUsers' }; // Filter tools by user types export const filterToolsByUserTypes = ( From 5b529a564a4375328a34743d90e188a3d696894c Mon Sep 17 00:00:00 2001 From: Conor Restall Date: Sat, 10 Jan 2026 13:23:59 +0000 Subject: [PATCH 027/164] feat: Improve tool search (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement token-based, whitespace-robust search in filterTools: * Normalize queries by trimming, lowercasing, and collapsing internal whitespace. * Split normalized query into tokens and require every token to match at least one of: localized name, description, shortDescription, or keywords. * Keep user-type filtering behavior the same and apply it before search filtering. * Return all tools (post user-type filter) when the normalized query is empty or only whitespace. * Make Hero autocomplete respect the new search behavior:* Use filterOptions={(options) => options} to disable MUI Autocomplete’s internal filtering. * Rely solely on filterTools + filteredTools so queries like "pdf " behave the same as "pdf" and support multi-word, order-insensitive searches. * Add unit tests for Phase 1 search behavior: * New src/tools/index.test.ts covering: * Empty and whitespace-only queries returning all tools. * Word-order and extra-whitespace robustness for queries like "pdf merge" vs " merge pdf ". * Case-insensitive matching and multi-word queries for the Base64 tool. * Trailing spaces being ignored. * Non-English behavior by using localized Spanish strings (e.g. "unir pdf") to confirm locale-agnostic token matching. Co-Authored-By: Warp --- src/components/Hero.tsx | 2 + src/tools/index.test.ts | 83 +++++++++++++++++++++++++++++++++++++++++ src/tools/index.ts | 33 ++++++++++------ 3 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 src/tools/index.test.ts diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 0404216..b239787 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -150,6 +150,8 @@ export default function Hero() { sx={{ mb: 2 }} autoHighlight options={filteredTools} + // Disable default MUI filtering since we already apply custom filterTools + filterOptions={(options) => options} groupBy={(option) => option.type} renderGroup={(params) => { return ( diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts new file mode 100644 index 0000000..d0360a1 --- /dev/null +++ b/src/tools/index.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import type { TFunction } from 'i18next'; +import { filterTools } from './index'; +import type { DefinedTool } from './defineTool'; +import type { FullI18nKey, I18nNamespaces } from '../i18n'; + +const mergePdfTool = { + type: 'pdf', + path: 'pdf/merge-pdf', + name: 'pdf:mergePdf.title' as FullI18nKey, + description: 'pdf:mergePdf.description' as FullI18nKey, + shortDescription: 'pdf:mergePdf.shortDescription' as FullI18nKey, + icon: 'icon', + keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'], + component: (() => null) as unknown, + userTypes: ['generalUsers'] +} as unknown as DefinedTool; + +const base64Tool = { + type: 'string', + path: 'string/base64', + name: 'string:base64.title' as FullI18nKey, + description: 'string:base64.description' as FullI18nKey, + shortDescription: 'string:base64.shortDescription' as FullI18nKey, + icon: 'icon', + keywords: ['base64'], + component: (() => null) as unknown, + userTypes: ['generalUsers', 'developers'] +} as unknown as DefinedTool; + +type NamespacedT = TFunction; + +const enTranslations: Record = { + 'pdf:mergePdf.title': 'Merge PDF', + 'pdf:mergePdf.description': 'Merge multiple PDF files into one document', + 'pdf:mergePdf.shortDescription': 'Merge PDF files', + 'string:base64.title': 'Base64 Encoder/Decoder', + 'string:base64.description': 'Encode or decode Base64 text', + 'string:base64.shortDescription': 'Convert text to and from Base64' +}; + +const esTranslations: Record = { + 'pdf:mergePdf.title': 'Unir PDF', + 'pdf:mergePdf.description': 'Unir varios archivos PDF en un solo documento', + 'pdf:mergePdf.shortDescription': 'Unir archivos PDF' +}; + +const makeT = (dict: Record): NamespacedT => + ((key: string) => dict[key] ?? key) as unknown as NamespacedT; + +describe('filterTools token-based search (Phase 1)', () => { + const tools = [mergePdfTool, base64Tool]; + const tEn = makeT(enTranslations); + const tEs = makeT(esTranslations); + + it('returns all tools when query is empty or whitespace', () => { + expect(filterTools(tools, '', [], tEn)).toEqual(tools); + expect(filterTools(tools, ' ', [], tEn)).toEqual(tools); + }); + + it('matches tools regardless of word order and extra whitespace', () => { + const result1 = filterTools(tools, 'pdf merge', [], tEn); + const result2 = filterTools(tools, ' merge pdf ', [], tEn); + + expect(result1).toContain(mergePdfTool); + expect(result2).toContain(mergePdfTool); + }); + + it('matches base64 tool with different queries and is case-insensitive', () => { + expect(filterTools(tools, 'Base64', [], tEn)).toContain(base64Tool); + expect(filterTools(tools, 'base64 encoder', [], tEn)).toContain(base64Tool); + }); + + it('ignores trailing spaces', () => { + const result = filterTools(tools, 'pdf merge ', [], tEn); + expect(result).toContain(mergePdfTool); + }); + + it('works with non-English localized strings', () => { + const result = filterTools(tools, 'unir pdf', [], tEs); + expect(result).toContain(mergePdfTool); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 1dbeb2f..41c8310 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -194,20 +194,29 @@ export const filterTools = ( filteredTools = filterToolsByUserTypes(tools, userTypes); } - // Then filter by search query - if (!query) return filteredTools; + // Normalize query: trim, collapse internal whitespace, lowercase, split into tokens + const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, ' '); - const lowerCaseQuery = query.toLowerCase(); + // If query is empty after normalization, return all tools (after user-type filtering) + if (!normalizedQuery) return filteredTools; - return filteredTools.filter( - (tool) => - t(tool.name).toLowerCase().includes(lowerCaseQuery) || - t(tool.description).toLowerCase().includes(lowerCaseQuery) || - t(tool.shortDescription).toLowerCase().includes(lowerCaseQuery) || - tool.keywords.some((keyword) => - keyword.toLowerCase().includes(lowerCaseQuery) - ) - ); + const tokens = normalizedQuery.split(' ').filter(Boolean); + + if (tokens.length === 0) return filteredTools; + + return filteredTools.filter((tool) => { + const searchableTexts = [ + t(tool.name), + t(tool.description), + t(tool.shortDescription), + ...tool.keywords + ].map((text) => text.toLowerCase()); + + // Require that every query token appears in at least one of the searchable strings + return tokens.every((token) => + searchableTexts.some((text) => text.includes(token)) + ); + }); }; export const getToolsByCategory = ( From a5ea80f1f34297b7f66ba674550227e331d96e21 Mon Sep 17 00:00:00 2001 From: Conor Restall Date: Sat, 10 Jan 2026 21:31:52 +0000 Subject: [PATCH 028/164] feat: Improve tool search normalization for alias queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Extend filterTools query normalization with alpha+digit token concatenation (e.g. base + 64 -> base64) • Document keyword-based synonym usage in filterTools • Expand filterTools tests to cover alias-style queries like pdf join and base 64 Co-Authored-By: Warp --- src/tools/index.test.ts | 28 +++++++++++++++++++++++++--- src/tools/index.ts | 23 ++++++++++++++++++++--- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index d0360a1..98982ed 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -11,7 +11,16 @@ const mergePdfTool = { description: 'pdf:mergePdf.description' as FullI18nKey, shortDescription: 'pdf:mergePdf.shortDescription' as FullI18nKey, icon: 'icon', - keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'], + keywords: [ + 'pdf', + 'merge', + 'extract', + 'pages', + 'combine', + 'document', + 'join', + 'append' + ], component: (() => null) as unknown, userTypes: ['generalUsers'] } as unknown as DefinedTool; @@ -23,7 +32,7 @@ const base64Tool = { description: 'string:base64.description' as FullI18nKey, shortDescription: 'string:base64.shortDescription' as FullI18nKey, icon: 'icon', - keywords: ['base64'], + keywords: ['b64'], component: (() => null) as unknown, userTypes: ['generalUsers', 'developers'] } as unknown as DefinedTool; @@ -48,7 +57,7 @@ const esTranslations: Record = { const makeT = (dict: Record): NamespacedT => ((key: string) => dict[key] ?? key) as unknown as NamespacedT; -describe('filterTools token-based search (Phase 1)', () => { +describe('filterTools token-based search', () => { const tools = [mergePdfTool, base64Tool]; const tEn = makeT(enTranslations); const tEs = makeT(esTranslations); @@ -71,6 +80,19 @@ describe('filterTools token-based search (Phase 1)', () => { expect(filterTools(tools, 'base64 encoder', [], tEn)).toContain(base64Tool); }); + it('matches tools using keyword-based English synonyms', () => { + const result = filterTools(tools, 'pdf join', [], tEn); + expect(result).toContain(mergePdfTool); + }); + + it('matches base64 tool with spaced and short-form variants', () => { + const resultBase64Spaced = filterTools(tools, 'base 64', [], tEn); + const resultB64 = filterTools(tools, 'b64', [], tEn); + + expect(resultBase64Spaced).toContain(base64Tool); + expect(resultB64).toContain(base64Tool); + }); + it('ignores trailing spaces', () => { const result = filterTools(tools, 'pdf merge ', [], tEn); expect(result).toContain(mergePdfTool); diff --git a/src/tools/index.ts b/src/tools/index.ts index 41c8310..52611ae 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -200,11 +200,28 @@ export const filterTools = ( // If query is empty after normalization, return all tools (after user-type filtering) if (!normalizedQuery) return filteredTools; - const tokens = normalizedQuery.split(' ').filter(Boolean); + const rawTokens = normalizedQuery.split(' ').filter(Boolean); - if (tokens.length === 0) return filteredTools; + if (rawTokens.length === 0) return filteredTools; + + // Expand tokens with simple alpha+digit concatenation variants, e.g. "base" + "64" -> "base64". + // This, combined with per-tool `keywords`, allows us to support aliases like + // "base 64" / "base64" / "b64" without requiring every form in every keyword list. + const tokens: string[] = [...rawTokens]; + + for (let i = 0; i < rawTokens.length - 1; i += 1) { + const current = rawTokens[i]; + const next = rawTokens[i + 1]; + + if (/^[a-zA-Z]+$/.test(current) && /^\d+$/.test(next)) { + tokens.push(`${current}${next}`); + } + } return filteredTools.filter((tool) => { + // `keywords` act as per-tool, potentially multi-language synonyms/aliases used only for search. + // They are never displayed directly, so they may intentionally contain phrases from + // different languages (e.g. "join pdf", "unir pdf", "fusionner pdf"). const searchableTexts = [ t(tool.name), t(tool.description), @@ -212,7 +229,7 @@ export const filterTools = ( ...tool.keywords ].map((text) => text.toLowerCase()); - // Require that every query token appears in at least one of the searchable strings + // Require that every query token (original or derived) appears in at least one of the searchable strings return tokens.every((token) => searchableTexts.some((text) => text.includes(token)) ); From 8da70a79853e0e34c48ccc567b1a745f35a2bc76 Mon Sep 17 00:00:00 2001 From: Conor Restall Date: Sat, 10 Jan 2026 22:30:09 +0000 Subject: [PATCH 029/164] feat: tidy up some search keywords --- src/pages/tools/csv/csv-to-xml/meta.ts | 2 +- src/pages/tools/csv/csv-to-yaml/meta.ts | 2 +- src/pages/tools/image/generic/change-colors/meta.ts | 2 +- src/pages/tools/image/generic/image-to-text/meta.ts | 4 +--- src/pages/tools/image/generic/qr-code/meta.ts | 6 ++++-- .../tools/image/generic/remove-background/meta.ts | 10 +--------- .../tools/number/generic-calc/data/sphereArea.ts | 12 +----------- .../tools/number/generic-calc/data/sphereVolume.ts | 13 +------------ .../number/generic-calc/data/voltageDropInWire.ts | 13 +------------ src/pages/tools/number/generic-calc/meta.ts | 2 +- src/pages/tools/pdf/compress-pdf/meta.ts | 4 +--- src/pages/tools/pdf/merge-pdf/meta.ts | 2 +- src/pages/tools/pdf/protect-pdf/meta.ts | 2 -- src/pages/tools/pdf/rotate-pdf/meta.ts | 10 +++++++++- src/pages/tools/pdf/split-pdf/meta.ts | 2 +- src/pages/tools/string/base64/meta.ts | 2 +- .../tools/string/hidden-character-detector/meta.ts | 11 +---------- src/pages/tools/string/to-morse/meta.ts | 2 +- src/pages/tools/string/url-decode/meta.ts | 11 +---------- src/pages/tools/string/url-encode/meta.ts | 2 +- 20 files changed, 30 insertions(+), 84 deletions(-) diff --git a/src/pages/tools/csv/csv-to-xml/meta.ts b/src/pages/tools/csv/csv-to-xml/meta.ts index bbaae24..f8bf280 100644 --- a/src/pages/tools/csv/csv-to-xml/meta.ts +++ b/src/pages/tools/csv/csv-to-xml/meta.ts @@ -11,6 +11,6 @@ export const tool = defineTool('csv', { path: 'csv-to-xml', icon: 'mdi-light:xml', - keywords: ['csv', 'xml', 'convert', 'transform', 'parse'], + keywords: ['convert', 'transform', 'parse'], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/csv/csv-to-yaml/meta.ts b/src/pages/tools/csv/csv-to-yaml/meta.ts index 67b7f9a..c7cd758 100644 --- a/src/pages/tools/csv/csv-to-yaml/meta.ts +++ b/src/pages/tools/csv/csv-to-yaml/meta.ts @@ -11,6 +11,6 @@ export const tool = defineTool('csv', { path: 'csv-to-yaml', icon: 'nonicons:yaml-16', - keywords: ['csv', 'to', 'yaml'], + keywords: ['comma', 'separated', 'values', 'transform', 'markup', 'convert'], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/image/generic/change-colors/meta.ts b/src/pages/tools/image/generic/change-colors/meta.ts index 6594fcc..8a97eca 100644 --- a/src/pages/tools/image/generic/change-colors/meta.ts +++ b/src/pages/tools/image/generic/change-colors/meta.ts @@ -11,6 +11,6 @@ export const tool = defineTool('image-generic', { path: 'change-colors', icon: 'cil:color-fill', - keywords: ['change', 'colors', 'in', 'png', 'image', 'jpg'], + keywords: ['png', 'jpg', 'replace', 'swap'], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/image/generic/image-to-text/meta.ts b/src/pages/tools/image/generic/image-to-text/meta.ts index 501023a..60aa251 100644 --- a/src/pages/tools/image/generic/image-to-text/meta.ts +++ b/src/pages/tools/image/generic/image-to-text/meta.ts @@ -12,10 +12,8 @@ export const tool = defineTool('image-generic', { icon: 'mdi:text-recognition', // Iconify icon as a string keywords: [ - 'ocr', 'optical character recognition', - 'image to text', - 'extract text', + 'extract', 'scan', 'tesseract', 'jpg', diff --git a/src/pages/tools/image/generic/qr-code/meta.ts b/src/pages/tools/image/generic/qr-code/meta.ts index ee3ab35..d4c4e96 100644 --- a/src/pages/tools/image/generic/qr-code/meta.ts +++ b/src/pages/tools/image/generic/qr-code/meta.ts @@ -11,8 +11,9 @@ export const tool = defineTool('image-generic', { path: 'qr-code', icon: 'mdi:qrcode', // Iconify icon as a string keywords: [ - 'qr code', 'qrcode', + 'quick', + 'read', 'generator', 'url', 'text', @@ -21,7 +22,8 @@ export const tool = defineTool('image-generic', { 'sms', 'wifi', 'vcard', - 'contact' + 'contact', + 'barcode' ], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/image/generic/remove-background/meta.ts b/src/pages/tools/image/generic/remove-background/meta.ts index 8bf12d0..522f1a4 100644 --- a/src/pages/tools/image/generic/remove-background/meta.ts +++ b/src/pages/tools/image/generic/remove-background/meta.ts @@ -11,14 +11,6 @@ export const tool = defineTool('image-generic', { path: 'remove-background', icon: 'mdi:image-remove', - keywords: [ - 'remove', - 'background', - 'png', - 'transparent', - 'image', - 'ai', - 'jpg' - ], + keywords: ['png', 'transparent', 'image', 'ai', 'jpg', 'backing', 'backdrop'], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/number/generic-calc/data/sphereArea.ts b/src/pages/tools/number/generic-calc/data/sphereArea.ts index 029a4a6..b4cc50f 100644 --- a/src/pages/tools/number/generic-calc/data/sphereArea.ts +++ b/src/pages/tools/number/generic-calc/data/sphereArea.ts @@ -2,17 +2,7 @@ import type { GenericCalcType } from './types'; const areaSphere: GenericCalcType = { icon: 'ph:sphere-duotone', - keywords: [ - 'sphere', - 'area', - 'surface area', - 'geometry', - 'mathematics', - 'radius', - 'calculation', - '3D', - 'shape' - ], + keywords: ['geometry', 'radius', '3d'], path: 'area-sphere', formula: 'A = 4 * pi * r**2', presets: [], diff --git a/src/pages/tools/number/generic-calc/data/sphereVolume.ts b/src/pages/tools/number/generic-calc/data/sphereVolume.ts index 07c629f..30225ef 100644 --- a/src/pages/tools/number/generic-calc/data/sphereVolume.ts +++ b/src/pages/tools/number/generic-calc/data/sphereVolume.ts @@ -2,18 +2,7 @@ import type { GenericCalcType } from './types'; const volumeSphere: GenericCalcType = { icon: 'gravity-ui:sphere', - keywords: [ - 'sphere', - 'volume', - 'geometry', - 'mathematics', - 'radius', - 'diameter', - 'calculation', - '3D', - 'shape', - 'capacity' - ], + keywords: ['geometry', 'radius', 'diameter', 'capacity', '3d'], i18n: { name: 'number:sphereVolume.title', description: 'number:sphereVolume.description', diff --git a/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts b/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts index 3bae1a3..3ca24ef 100644 --- a/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts +++ b/src/pages/tools/number/generic-calc/data/voltageDropInWire.ts @@ -4,18 +4,7 @@ import wire_gauge from '../../../../../datatables/data/wire_gauge'; const voltageDropInWire: GenericCalcType = { icon: 'simple-icons:wire', - keywords: [ - 'voltage drop', - 'cable', - 'wire', - 'electrical', - 'resistance', - 'power loss', - 'conductor', - 'resistivity', - 'AWG', - 'gauge' - ], + keywords: ['awg', 'gauge', 'resistivity', 'conductor'], path: 'cable-voltage-drop', formula: 'x = (((p * L) / (A/10**6) ) *2) * I', i18n: { diff --git a/src/pages/tools/number/generic-calc/meta.ts b/src/pages/tools/number/generic-calc/meta.ts index 5bca9ee..21a1703 100644 --- a/src/pages/tools/number/generic-calc/meta.ts +++ b/src/pages/tools/number/generic-calc/meta.ts @@ -19,7 +19,7 @@ allGenericCalcs.forEach((x) => { defineTool('number', { ...x, path: 'generic-calc/' + x.path, - keywords: ['calculator', 'math', ...x.keywords], + keywords: [...x.keywords], component: lazy(importComponent2) }) ); diff --git a/src/pages/tools/pdf/compress-pdf/meta.ts b/src/pages/tools/pdf/compress-pdf/meta.ts index f0a414c..e684330 100644 --- a/src/pages/tools/pdf/compress-pdf/meta.ts +++ b/src/pages/tools/pdf/compress-pdf/meta.ts @@ -6,13 +6,11 @@ export const tool = defineTool('pdf', { icon: 'material-symbols:compress', keywords: [ - 'pdf', - 'compress', 'reduce', 'size', 'optimize', 'shrink', - 'file size', + 'file', 'ghostscript', 'secure', 'private', diff --git a/src/pages/tools/pdf/merge-pdf/meta.ts b/src/pages/tools/pdf/merge-pdf/meta.ts index 33ba46c..47eaeb0 100644 --- a/src/pages/tools/pdf/merge-pdf/meta.ts +++ b/src/pages/tools/pdf/merge-pdf/meta.ts @@ -4,7 +4,7 @@ import { lazy } from 'react'; export const meta = defineTool('pdf', { icon: 'material-symbols-light:merge', component: lazy(() => import('./index')), - keywords: ['pdf', 'merge', 'extract', 'pages', 'combine', 'document'], + keywords: ['pages', 'combine', 'document', 'join', 'append'], path: 'merge-pdf', i18n: { name: 'pdf:mergePdf.title', diff --git a/src/pages/tools/pdf/protect-pdf/meta.ts b/src/pages/tools/pdf/protect-pdf/meta.ts index 7876f1d..d3a20df 100644 --- a/src/pages/tools/pdf/protect-pdf/meta.ts +++ b/src/pages/tools/pdf/protect-pdf/meta.ts @@ -6,8 +6,6 @@ export const tool = defineTool('pdf', { icon: 'material-symbols:lock', keywords: [ - 'pdf', - 'protect', 'password', 'secure', 'encrypt', diff --git a/src/pages/tools/pdf/rotate-pdf/meta.ts b/src/pages/tools/pdf/rotate-pdf/meta.ts index e037ff4..a9670a6 100644 --- a/src/pages/tools/pdf/rotate-pdf/meta.ts +++ b/src/pages/tools/pdf/rotate-pdf/meta.ts @@ -13,6 +13,14 @@ export const tool = defineTool('pdf', { path: 'rotate-pdf', icon: 'carbon:rotate', - keywords: ['pdf', 'rotate', 'rotation', 'document', 'pages', 'orientation'], + keywords: [ + 'rotation', + 'document', + 'pages', + 'orientation', + 'oritent', + 'spin', + 'flip' + ], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/pdf/split-pdf/meta.ts b/src/pages/tools/pdf/split-pdf/meta.ts index 0352c5b..687de62 100644 --- a/src/pages/tools/pdf/split-pdf/meta.ts +++ b/src/pages/tools/pdf/split-pdf/meta.ts @@ -4,7 +4,7 @@ import { lazy } from 'react'; export const meta = defineTool('pdf', { icon: 'material-symbols-light:call-split-rounded', component: lazy(() => import('./index')), - keywords: ['pdf', 'split', 'extract', 'pages', 'range', 'document'], + keywords: ['extract', 'pages', 'range', 'document', 'remove'], path: 'split-pdf', i18n: { name: 'pdf:splitPdf.title', diff --git a/src/pages/tools/string/base64/meta.ts b/src/pages/tools/string/base64/meta.ts index d51eb7f..84a89b8 100644 --- a/src/pages/tools/string/base64/meta.ts +++ b/src/pages/tools/string/base64/meta.ts @@ -5,7 +5,7 @@ export const tool = defineTool('string', { path: 'base64', icon: 'tabler:number-64-small', - keywords: ['base64'], + keywords: ['b64'], component: lazy(() => import('./index')), i18n: { name: 'string:base64.title', diff --git a/src/pages/tools/string/hidden-character-detector/meta.ts b/src/pages/tools/string/hidden-character-detector/meta.ts index 83a39a6..f8919c4 100644 --- a/src/pages/tools/string/hidden-character-detector/meta.ts +++ b/src/pages/tools/string/hidden-character-detector/meta.ts @@ -11,15 +11,6 @@ export const tool = defineTool('string', { }, path: 'hidden-character-detector', icon: 'material-symbols:visibility-off', - keywords: [ - 'hidden', - 'character', - 'detector', - 'unicode', - 'rtl', - 'override', - 'security', - 'invisible' - ], + keywords: ['unicode', 'rtl', 'override', 'security', 'invisible'], component: lazy(() => import('./index')) }); diff --git a/src/pages/tools/string/to-morse/meta.ts b/src/pages/tools/string/to-morse/meta.ts index b15a757..eb7b03c 100644 --- a/src/pages/tools/string/to-morse/meta.ts +++ b/src/pages/tools/string/to-morse/meta.ts @@ -5,7 +5,7 @@ import { lazy } from 'react'; export const tool = defineTool('string', { path: 'to-morse', icon: 'arcticons:morse', - keywords: ['to', 'morse'], + keywords: [], component: lazy(() => import('./index')), i18n: { name: 'string:toMorse.title', diff --git a/src/pages/tools/string/url-decode/meta.ts b/src/pages/tools/string/url-decode/meta.ts index d814fd9..a1d3bb6 100644 --- a/src/pages/tools/string/url-decode/meta.ts +++ b/src/pages/tools/string/url-decode/meta.ts @@ -5,16 +5,7 @@ export const tool = defineTool('string', { path: 'url-decode-string', icon: 'codicon:symbol-string', - keywords: [ - 'url', - 'decode', - 'string', - 'url decode', - 'unescape', - 'encoding', - 'percent', - 'decode url' - ], + keywords: ['string', 'unescape', 'encoding', 'percent', 'decoding'], component: lazy(() => import('./index')), i18n: { name: 'string:urlDecode.toolInfo.title', diff --git a/src/pages/tools/string/url-encode/meta.ts b/src/pages/tools/string/url-encode/meta.ts index b83b406..2519386 100644 --- a/src/pages/tools/string/url-encode/meta.ts +++ b/src/pages/tools/string/url-encode/meta.ts @@ -5,7 +5,7 @@ export const tool = defineTool('string', { path: 'url-encode-string', icon: 'ic:baseline-percentage', - keywords: ['url', 'encode', 'string', 'url encode', 'encoding', 'percent'], + keywords: ['string', 'encoding', 'percent', 'escape'], component: lazy(() => import('./index')), i18n: { name: 'string:urlEncode.toolInfo.title', From 328a423e0f4c22ee4947e9676a8815079aeada2b Mon Sep 17 00:00:00 2001 From: Conor Restall Date: Sat, 10 Jan 2026 22:43:54 +0000 Subject: [PATCH 030/164] feat: Improve tool search scoring and typo tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add weighted scoring and Levenshtein-based fuzzy matching in filterTools • Ensure title matches rank above description-only matches and keep existing token behavior --- src/tools/index.test.ts | 26 +++++++- src/tools/index.ts | 138 +++++++++++++++++++++++++++++++++++----- 2 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index 98982ed..9ba4b71 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -37,12 +37,26 @@ const base64Tool = { userTypes: ['generalUsers', 'developers'] } as unknown as DefinedTool; +const otherPdfTool = { + type: 'pdf', + path: 'pdf/other-pdf', + name: 'pdf:otherPdf.title' as FullI18nKey, + description: 'pdf:otherPdf.description' as FullI18nKey, + shortDescription: 'pdf:otherPdf.shortDescription' as FullI18nKey, + icon: 'icon', + keywords: [], + component: (() => null) as unknown, + userTypes: ['generalUsers'] +} as unknown as DefinedTool; type NamespacedT = TFunction; const enTranslations: Record = { 'pdf:mergePdf.title': 'Merge PDF', 'pdf:mergePdf.description': 'Merge multiple PDF files into one document', 'pdf:mergePdf.shortDescription': 'Merge PDF files', + 'pdf:otherPdf.title': 'Document helper', + 'pdf:otherPdf.description': 'Helper for pdf tasks', + 'pdf:otherPdf.shortDescription': 'PDF helper', 'string:base64.title': 'Base64 Encoder/Decoder', 'string:base64.description': 'Encode or decode Base64 text', 'string:base64.shortDescription': 'Convert text to and from Base64' @@ -58,7 +72,7 @@ const makeT = (dict: Record): NamespacedT => ((key: string) => dict[key] ?? key) as unknown as NamespacedT; describe('filterTools token-based search', () => { - const tools = [mergePdfTool, base64Tool]; + const tools = [mergePdfTool, base64Tool, otherPdfTool]; const tEn = makeT(enTranslations); const tEs = makeT(esTranslations); @@ -102,4 +116,14 @@ describe('filterTools token-based search', () => { const result = filterTools(tools, 'unir pdf', [], tEs); expect(result).toContain(mergePdfTool); }); + + it('tolerates single-character typos in query tokens', () => { + const result = filterTools(tools, 'merhe pdf', [], tEn); + expect(result[0]).toBe(mergePdfTool); + }); + + it('ranks tools with title matches above description-only matches', () => { + const result = filterTools(tools, 'pdf', [], tEn); + expect(result[0]).toBe(mergePdfTool); + }); }); diff --git a/src/tools/index.ts b/src/tools/index.ts index 52611ae..867af5d 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -181,6 +181,111 @@ export const filterToolsByUserTypes = ( // 'tools', // tools.map(({ name, type }) => ({ type, name })) // ); + +const levenshtein = (a: string, b: string): number => { + if (a === b) return 0; + const aLen = a.length; + const bLen = b.length; + + if (aLen === 0) return bLen; + if (bLen === 0) return aLen; + + const dp: number[][] = Array.from({ length: aLen + 1 }, () => + Array(bLen + 1).fill(0) + ); + + for (let i = 0; i <= aLen; i += 1) dp[i][0] = i; + for (let j = 0; j <= bLen; j += 1) dp[0][j] = j; + + for (let i = 1; i <= aLen; i += 1) { + for (let j = 1; j <= bLen; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ); + } + } + + return dp[aLen][bLen]; +}; + +type SearchField = { text: string; weight: number }; + +const splitWords = (text: string): string[] => + text.split(/[^a-z0-9]+/g).filter(Boolean); + +const computeToolScore = ( + tool: DefinedTool, + tokens: string[], + t: TFunction +): number => { + const fields: SearchField[] = [ + { text: t(tool.name).toLowerCase(), weight: 5 }, + { text: t(tool.shortDescription).toLowerCase(), weight: 3 }, + { text: t(tool.description).toLowerCase(), weight: 2 }, + ...(tool.keywords ?? []).map((kw) => ({ + text: kw.toLowerCase(), + weight: 4 + })) + ]; + + let totalScore = 0; + + for (const token of tokens) { + if (!token) continue; + + let bestForToken = 0; + + for (const field of fields) { + const text = field.text; + if (!text) continue; + + let fieldScore = 0; + + if (text.includes(token)) { + // Base score for substring match + fieldScore = field.weight * 2; + + const words = splitWords(text); + if (words.includes(token)) { + // Boost whole-word matches + fieldScore += field.weight; + } + } else { + const words = splitWords(text); + + for (const word of words) { + if (!word) continue; + + // Quick length check before full distance calculation + if (Math.abs(word.length - token.length) > 1) continue; + + const dist = levenshtein(token, word); + if (dist === 1) { + fieldScore = Math.max(fieldScore, field.weight); + } + } + } + + if (fieldScore > bestForToken) { + bestForToken = fieldScore; + } + } + + // If any token fails to match (exact or fuzzy), treat the tool as a non-match. + if (bestForToken === 0) { + return 0; + } + + totalScore += bestForToken; + } + + return totalScore; +}; + export const filterTools = ( tools: DefinedTool[], query: string, @@ -194,14 +299,13 @@ export const filterTools = ( filteredTools = filterToolsByUserTypes(tools, userTypes); } - // Normalize query: trim, collapse internal whitespace, lowercase, split into tokens + // Normalize query: trim, collapse internal whitespace, lowercase const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, ' '); // If query is empty after normalization, return all tools (after user-type filtering) if (!normalizedQuery) return filteredTools; const rawTokens = normalizedQuery.split(' ').filter(Boolean); - if (rawTokens.length === 0) return filteredTools; // Expand tokens with simple alpha+digit concatenation variants, e.g. "base" + "64" -> "base64". @@ -218,22 +322,22 @@ export const filterTools = ( } } - return filteredTools.filter((tool) => { - // `keywords` act as per-tool, potentially multi-language synonyms/aliases used only for search. - // They are never displayed directly, so they may intentionally contain phrases from - // different languages (e.g. "join pdf", "unir pdf", "fusionner pdf"). - const searchableTexts = [ - t(tool.name), - t(tool.description), - t(tool.shortDescription), - ...tool.keywords - ].map((text) => text.toLowerCase()); + const scored = filteredTools + .map((tool) => ({ + tool, + score: computeToolScore(tool, tokens, t) + })) + .filter(({ score }) => score > 0) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; - // Require that every query token (original or derived) appears in at least one of the searchable strings - return tokens.every((token) => - searchableTexts.some((text) => text.includes(token)) - ); - }); + const aName = t(a.tool.name).toLowerCase(); + const bName = t(b.tool.name).toLowerCase(); + + return aName.localeCompare(bName); + }); + + return scored.map(({ tool }) => tool); }; export const getToolsByCategory = ( From 196c76a830e331225c3b9c99d1a1fa3003af3696 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sat, 14 Feb 2026 19:27:15 +0100 Subject: [PATCH 031/164] Improve clarity for List Group tool (issue #308) Co-authored-by: iSwiin --- public/locales/en/list.json | 40 +++---- src/pages/tools/list/group/index.tsx | 145 ++++++++++++++++++++------ src/pages/tools/list/group/meta.ts | 12 +-- src/pages/tools/list/group/service.ts | 52 ++++----- 4 files changed, 164 insertions(+), 85 deletions(-) diff --git a/public/locales/en/list.json b/public/locales/en/list.json index ddb04d1..6a6788b 100644 --- a/public/locales/en/list.json +++ b/public/locales/en/list.json @@ -113,36 +113,36 @@ "trimItemsDescription": "Remove leading and trailing spaces before comparing items.", "uniqueItemOptions": "Unique Item Options" }, - "group": { + "chunk": { "deleteEmptyItems": "Delete Empty Items", - "deleteEmptyItemsDescription": "Ignore empty items and don't include them in the groups.", - "description": "World's simplest browser-based utility for grouping list items. Input your list and specify grouping criteria to organize items into logical groups. Perfect for categorizing data, organizing information, or creating structured lists. Supports custom separators and various grouping options.", + "deleteEmptyItemsDescription": "Ignore empty items and exclude them from the result.", + "description": "Split a list into consecutive chunks of a fixed size. This tool separates input items, chunks them sequentially, optionally pads incomplete chunks, and formats the output using custom separators and wrappers.", "emptyItemsAndPadding": "Empty Items and Padding", - "groupNumberDescription": "Number of items in a group", - "groupSeparatorDescription": "Group separator character", - "groupSizeAndSeparators": "Group Size and Separators", + "groupNumberDescription": "Number of items per group", + "groupSeparatorDescription": "Separator placed between groups", + "groupSizeAndSeparators": "Group Size and Output Format", "inputItemSeparator": "Input Item Separator", "inputTitle": "Input list", - "itemSeparatorDescription": "Item separator character", - "leftWrapDescription": "Group's left wrap symbol.", - "padNonFullGroups": "Pad Non-full Groups", - "padNonFullGroupsDescription": "Fill non-full groups with a custom item (enter below).", - "paddingCharDescription": "Use this character or item to pad non-full groups.", - "resultTitle": "Grouped items", - "rightWrapDescription": "Group's right wrap symbol.", - "shortDescription": "Group list items by common properties", + "itemSeparatorDescription": "Separator placed between items inside a group", + "leftWrapDescription": "Symbol added before each group", + "padNonFullGroups": "Pad Incomplete Groups", + "padNonFullGroupsDescription": "Fill the last group if it has fewer items than required.", + "paddingCharDescription": "Character or value used to pad incomplete groups.", + "resultTitle": "Grouped output", + "rightWrapDescription": "Symbol added after each group", + "shortDescription": "Split a list into fixed-size chunks", "splitOperators": { "regex": { - "description": "Delimit input list items with a regular expression.", - "title": "Use a Regex for Splitting" + "description": "Split input items using a regular expression.", + "title": "Use Regex Splitting" }, "symbol": { - "description": "Delimit input list items with a character.", - "title": "Use a Symbol for Splitting" + "description": "Split input items using a single character.", + "title": "Use Symbol Splitting" } }, - "splitSeparatorDescription": "Set a delimiting symbol or regular expression.", - "title": "Group" + "splitSeparatorDescription": "Delimiter symbol or regular expression used to split input items.", + "title": "Chunk List" }, "reverse": { "description": "This is a super simple browser-based application prints all list items in reverse. The input items can be separated by any symbol and you can also change the separator of the reversed list items.", diff --git a/src/pages/tools/list/group/index.tsx b/src/pages/tools/list/group/index.tsx index 84377be..968bbb0 100644 --- a/src/pages/tools/list/group/index.tsx +++ b/src/pages/tools/list/group/index.tsx @@ -2,9 +2,10 @@ import { Box } from '@mui/material'; import React, { useState } from 'react'; import ToolTextInput from '@components/input/ToolTextInput'; import ToolTextResult from '@components/result/ToolTextResult'; -import { groupList, SplitOperatorType } from './service'; +import { chunkList, SplitOperatorType } from './service'; import SimpleRadio from '@components/options/SimpleRadio'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import { CardExampleType } from '@components/examples/ToolExamples'; import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; import { formatNumber } from '../../../../utils/number'; import ToolContent from '@components/ToolContent'; @@ -23,24 +24,101 @@ const initialValues = { padNonFullGroup: false, paddingChar: '...' }; -const splitOperators: { - title: string; - description: string; - type: SplitOperatorType; -}[] = [ + +type InitialValuesType = typeof initialValues; + +const splitOperators: SplitOperatorType[] = ['symbol', 'regex']; + +const exampleCards: CardExampleType[] = [ { - title: 'Use a Symbol for Splitting', - description: 'Delimit input list items with a character.', - type: 'symbol' + title: 'Group Hexagon Coordinates', + description: + 'In this example, we group the coordinates of a regular hexagon. The input coordinates are given as a space-separated list "x1 y1 x2 y2 x3 y3 …". What we want to do is create vector point pairs such as "(x1, y1); (x2, y2); (x3, y3); …". To do that, we use the space character as the input coordinate separator, and to create vectors, we group them by pairs. We wrap the coordinates in parentheses, put a comma between the x and y group items, and a semicolon between individual groups.', + sampleText: '2.5 9.33 0 5 2.5 0.66 7.5 0.66 10 5 7.5 9.33', + sampleResult: `(2.5, 9.33); (0, 5); (2.5, 0.66); (7.5, 0.66); (10, 5); (7.5, 9.33)`, + sampleOptions: { + splitOperatorType: 'symbol', + splitSeparator: ' ', + groupNumber: 2, + itemSeparator: ', ', + leftWrap: '(', + rightWrap: ')', + groupSeparator: '; ', + deleteEmptyItems: true, + padNonFullGroup: false, + paddingChar: 'x' + } }, { - title: 'Use a Regex for Splitting', - type: 'regex', - description: 'Delimit input list items with a regular expression.' + title: 'Chunks of Size 3', + description: + 'This example demonstrates grouping of list items and creates 9 groups of 3 items. The input list contains all alphabet letters (26 letters, separated by a semicolon) and the output is groups of letter trigrams. As the last group is missing one letter, we enable padding and add the underscore symbol as the padding element.', + sampleText: 'a;b;c;d;e;f;g;h;i;j;k;l;m;n;o;p;q;r;s;t;u;v;w;x;y;z', + sampleResult: `[a, b, c] +[d, e, f] +[g, h, i] +[j, k, l] +[m, n, o] +[p, q, r] +[s, t, u] +[v, w, x] +[y, z, _]`, + sampleOptions: { + splitOperatorType: 'symbol', + splitSeparator: ';', + groupNumber: 3, + itemSeparator: ',', + leftWrap: '[', + rightWrap: ']', + groupSeparator: '\\n', + deleteEmptyItems: false, + padNonFullGroup: true, + paddingChar: '_' + } + }, + { + title: 'Convert a List to a TSV', + description: + 'In this example, we use our list item grouper to convert a food list to tab-separated values (TSV). As spaces are chaotically used between the items of the input list, we use the item separating regular expression "\\s+" to match them. We create a TSV with three columns (three groups), separate them with a tab character, and put newlines between the groups.', + sampleText: `beef buns + cake corn + crab +dill +fish + kiwi kale + + lime meat +mint + milk + pear plum + pate + pork rice +soup + tuna + tart`, + sampleResult: `beef buns cake +corn crab dill +fish kiwi kale +lime meat mint +milk pear plum +pate pork rice +soup tuna tart`, + sampleOptions: { + splitOperatorType: 'regex', + splitSeparator: '\\s+', + groupNumber: 3, + itemSeparator: '\\t', + leftWrap: '', + rightWrap: '', + groupSeparator: '\\n', + deleteEmptyItems: true, + padNonFullGroup: false, + paddingChar: 'x' + } } ]; -export default function FindUnique({ title }: ToolComponentProps) { +export default function ChunkList({ title }: ToolComponentProps) { const { t } = useTranslation('list'); const [input, setInput] = useState(''); const [result, setResult] = useState(''); @@ -59,7 +137,7 @@ export default function FindUnique({ title }: ToolComponentProps) { } = optionsValues; setResult( - groupList( + chunkList( splitOperatorType, splitSeparator, input, @@ -79,33 +157,34 @@ export default function FindUnique({ title }: ToolComponentProps) { } resultComponent={ - + } initialValues={initialValues} getGroups={({ values, updateField }) => [ { - title: t('group.inputItemSeparator'), + title: t('chunk.inputItemSeparator'), component: ( - {splitOperators.map(({ title, description, type }) => ( + {splitOperators.map((type) => ( updateField('splitOperatorType', type)} - title={t(`group.splitOperators.${type}.title`)} - description={t(`group.splitOperators.${type}.description`)} + title={t(`chunk.splitOperators.${type}.title`)} + description={t(`chunk.splitOperators.${type}.description`)} checked={values.splitOperatorType === type} /> ))} updateField('splitSeparator', val)} /> @@ -113,12 +192,12 @@ export default function FindUnique({ title }: ToolComponentProps) { ) }, { - title: t('group.groupSizeAndSeparators'), + title: t('chunk.groupSizeAndSeparators'), component: ( updateField('groupNumber', formatNumber(value, 1)) @@ -126,46 +205,46 @@ export default function FindUnique({ title }: ToolComponentProps) { /> updateField('itemSeparator', value)} /> updateField('groupSeparator', value)} /> updateField('leftWrap', value)} /> updateField('rightWrap', value)} /> ) }, { - title: t('group.emptyItemsAndPadding'), + title: t('chunk.emptyItemsAndPadding'), component: ( updateField('deleteEmptyItems', value)} /> updateField('padNonFullGroup', value)} /> updateField('paddingChar', value)} /> diff --git a/src/pages/tools/list/group/meta.ts b/src/pages/tools/list/group/meta.ts index d6fb646..76131ee 100644 --- a/src/pages/tools/list/group/meta.ts +++ b/src/pages/tools/list/group/meta.ts @@ -2,15 +2,15 @@ import { defineTool } from '@tools/defineTool'; import { lazy } from 'react'; export const tool = defineTool('list', { - path: 'group', - icon: 'pajamas:group', + path: 'chunk', + icon: 'mdi:rhombus-split', - keywords: ['group'], + keywords: ['chuck', 'list', 'partition', 'split'], component: lazy(() => import('./index')), i18n: { - name: 'list:group.title', - description: 'list:group.description', - shortDescription: 'list:group.shortDescription', + name: 'list:chunk.title', + description: 'list:chunk.description', + shortDescription: 'list:chunk.shortDescription', userTypes: ['generalUsers', 'developers'] } }); diff --git a/src/pages/tools/list/group/service.ts b/src/pages/tools/list/group/service.ts index dff7d7d..0772397 100644 --- a/src/pages/tools/list/group/service.ts +++ b/src/pages/tools/list/group/service.ts @@ -1,25 +1,25 @@ export type SplitOperatorType = 'symbol' | 'regex'; // function that split the array into an array of subarray of desired length -function groupMaker(array: string[], groupNumber: number): string[][] { +function chunkMaker(array: string[], chunkNumber: number): string[][] { const result: string[][] = []; - for (let i = 0; i < array.length; i += groupNumber) { - result.push(array.slice(i, i + groupNumber)); + for (let i = 0; i < array.length; i += chunkNumber) { + result.push(array.slice(i, i + chunkNumber)); } return result; } -// function use to handle the case paddingNonFullGroup is enable -function groupFiller( +// function use to handle the case paddingNonFullChunk is enable +function chunkFiller( array: string[][], - groupNumber: number, - padNonFullGroup: boolean, + chunkNumber: number, + padNonFullChunk: boolean, paddingChar: string = '' ): string[][] { - if (padNonFullGroup) { + if (padNonFullChunk) { const lastSubArray: string[] = array[array.length - 1]; - if (lastSubArray.length < groupNumber) { - for (let i = lastSubArray.length; i < groupNumber; i++) { + if (lastSubArray.length < chunkNumber) { + for (let i = lastSubArray.length; i < chunkNumber; i++) { lastSubArray.push(paddingChar); } } @@ -29,7 +29,7 @@ function groupFiller( } // function that join with the item separator and wrap with left and right each subArray of the Array -function groupJoinerAndWrapper( +function chunkJoinerAndWrapper( array: string[][], itemSeparator: string = '', leftWrap: string = '', @@ -40,23 +40,23 @@ function groupJoinerAndWrapper( }); } -export function groupList( +export function chunkList( splitOperatorType: SplitOperatorType, splitSeparator: string, input: string, - groupNumber: number, + chunkNumber: number, itemSeparator: string = '', leftWrap: string = '', rightWrap: string = '', - groupSeparator: string, + chunkSeparator: string, deleteEmptyItems: boolean, - padNonFullGroup: boolean, + padNonFullChunk: boolean, paddingChar: string = '' ): string { + if (!splitSeparator) return ''; + let array: string[]; - let splitedArray: string[][]; - let fullSplitedArray: string[][]; - let result: string[]; + switch (splitOperatorType) { case 'symbol': array = input.split(splitSeparator); @@ -71,24 +71,24 @@ export function groupList( } // split the input into an array of subArray with the desired length - splitedArray = groupMaker(array, groupNumber); + const splitedArray = chunkMaker(array, chunkNumber); - // fill the last subArray is PadNonFullGroup is enabled - fullSplitedArray = groupFiller( + // fill the last subArray is PadNonFullChunk is enabled + const fullSplitedArray = chunkFiller( splitedArray, - groupNumber, - padNonFullGroup, + chunkNumber, + padNonFullChunk, paddingChar ); // get the list of formated subArray with the item separator and left and right wrapper - result = groupJoinerAndWrapper( + const result = chunkJoinerAndWrapper( fullSplitedArray, itemSeparator, leftWrap, rightWrap ); - // finnaly join the group separator before returning - return result.join(groupSeparator); + // finnaly join the chunk separator before returning + return result.join(chunkSeparator); } From 5be0801292cf9c4a183ab460d2d105e6542dc609 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sat, 14 Feb 2026 19:36:01 +0100 Subject: [PATCH 032/164] service name updated in test file --- src/pages/tools/list/group/group.service.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/tools/list/group/group.service.test.ts b/src/pages/tools/list/group/group.service.test.ts index 5eed8d0..6fe72e1 100644 --- a/src/pages/tools/list/group/group.service.test.ts +++ b/src/pages/tools/list/group/group.service.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { groupList, SplitOperatorType } from './service'; +import { chunkList, SplitOperatorType } from './service'; -describe('groupList', () => { +describe('chunkList', () => { it('splits by symbol, groups, pads, and formats correctly', () => { const input = 'a,b,c,d,e,f,g,h,i,j'; const splitOperatorType: SplitOperatorType = 'symbol'; @@ -18,7 +18,7 @@ describe('groupList', () => { const expectedOutput = '[a-b-c] | [d-e-f] | [g-h-i] | [j-x-x]'; - const result = groupList( + const result = chunkList( splitOperatorType, splitSeparator, input, @@ -49,7 +49,7 @@ describe('groupList', () => { const expectedOutput = '(a,b,c,d) / (e,f,g,h) / (i,j)'; - const result = groupList( + const result = chunkList( splitOperatorType, splitSeparator, input, @@ -80,7 +80,7 @@ describe('groupList', () => { const expectedOutput = ' & & '; - const result = groupList( + const result = chunkList( splitOperatorType, splitSeparator, input, From 01049644f436a326ead77cda208087471006a3ae Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Sun, 15 Feb 2026 20:57:20 +0100 Subject: [PATCH 033/164] feat: byte conversion across systems (SI, IEC) feature closes #279 --- public/locales/en/number.json | 13 ++ .../byte-converter/byte-converter.test.ts | 77 ++++++++ .../tools/number/byte-converter/index.tsx | 167 ++++++++++++++++++ src/pages/tools/number/byte-converter/meta.ts | 16 ++ .../tools/number/byte-converter/service.ts | 36 ++++ .../tools/number/byte-converter/types.ts | 44 +++++ src/pages/tools/number/index.ts | 3 + 7 files changed, 356 insertions(+) create mode 100644 src/pages/tools/number/byte-converter/byte-converter.test.ts create mode 100644 src/pages/tools/number/byte-converter/index.tsx create mode 100644 src/pages/tools/number/byte-converter/meta.ts create mode 100644 src/pages/tools/number/byte-converter/service.ts create mode 100644 src/pages/tools/number/byte-converter/types.ts diff --git a/public/locales/en/number.json b/public/locales/en/number.json index bc2cfb4..f07f195 100644 --- a/public/locales/en/number.json +++ b/public/locales/en/number.json @@ -188,5 +188,18 @@ "longDescription": "This calculator helps determine the voltage drop and power loss in a two-conductor electrical cable. It takes into account the cable length, wire gauge (cross-sectional area), material resistivity, and current flow. The tool calculates the round-trip voltage drop, total resistance of the cable, and the power dissipated as heat. This is particularly useful for electrical engineers, electricians, and hobbyists when designing electrical systems to ensure voltage levels remain within acceptable limits at the load.", "shortDescription": "Calculate voltage drop and power loss in electrical cables based on length, material, and current", "title": "Round trip voltage drop in cable" + }, + "byteConverter": { + "description": "Convert data values between different byte units, supporting both SI (decimal) and IEC (binary) systems. It supports multiple lines conversion.", + "title": "Byte Converter", + "shortDescription": "Quickly convert values between byte units", + "inputTitle": "Input Values", + "outputTitle": "Converted Output", + "unit": { + "title": "Conversion Options", + "from": "Select the unit of the input values you are providing (e.g., KB, MB, GiB).", + "to": "Select the unit you want the input values to be converted into.", + "precision": "Number of decimal places to display in the converted output." + } } } diff --git a/src/pages/tools/number/byte-converter/byte-converter.test.ts b/src/pages/tools/number/byte-converter/byte-converter.test.ts new file mode 100644 index 0000000..37d2df7 --- /dev/null +++ b/src/pages/tools/number/byte-converter/byte-converter.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { byteConverter } from './service'; +import { InitialValuesType, DataUnit } from './types'; + +describe('byteConverter', () => { + const makeOptions = ( + fromUnit: DataUnit, + toUnit: DataUnit, + precision: number + ): InitialValuesType => ({ + fromUnit, + toUnit, + precision + }); + + it('converts bits to bytes', () => { + expect(byteConverter('8', makeOptions('b', 'B', 2))).toBe('1'); + expect(byteConverter('1', makeOptions('b', 'B', 4))).toBe('0.125'); + }); + + it('converts bytes to bits', () => { + expect(byteConverter('1', makeOptions('B', 'b', 0))).toBe('8'); + }); + + it('converts decimal units correctly', () => { + expect(byteConverter('1', makeOptions('KB', 'B', 0))).toBe('1000'); + expect(byteConverter('1', makeOptions('MB', 'KB', 0))).toBe('1000'); + expect(byteConverter('1', makeOptions('GB', 'MB', 0))).toBe('1000'); + }); + + it('converts binary units correctly', () => { + expect(byteConverter('1', makeOptions('KiB', 'B', 0))).toBe('1024'); + expect(byteConverter('1', makeOptions('MiB', 'KiB', 0))).toBe('1024'); + expect(byteConverter('1', makeOptions('GiB', 'MiB', 0))).toBe('1024'); + }); + + it('converts decimal to binary units correctly', () => { + expect(byteConverter('1', makeOptions('GB', 'MiB', 2))).toBe('953.67'); + expect(byteConverter('1', makeOptions('TB', 'GiB', 2))).toBe('931.32'); + }); + + it('converts binary to decimal units correctly', () => { + expect(byteConverter('1', makeOptions('GiB', 'MB', 2))).toBe('1073.74'); + expect(byteConverter('1', makeOptions('TiB', 'GB', 2))).toBe('1099.51'); + }); + + it('respects precision rounding', () => { + expect(byteConverter('1', makeOptions('GB', 'MiB', 0))).toBe('954'); + expect(byteConverter('1', makeOptions('GB', 'MiB', 1))).toBe('953.7'); + expect(byteConverter('1', makeOptions('GB', 'MiB', 3))).toBe('953.674'); + }); + + it('handles zero correctly', () => { + expect(byteConverter('0', makeOptions('GB', 'MB', 2))).toBe('0'); + }); + + it('handles identity conversions', () => { + expect(byteConverter('123', makeOptions('MB', 'MB', 2))).toBe('123'); + expect(byteConverter('1', makeOptions('GiB', 'GiB', 5))).toBe('1'); + }); + + it('handles large values', () => { + expect(byteConverter('1024', makeOptions('GiB', 'TiB', 4))).toBe('1'); + }); + + it('handles multiline input', () => { + const input = '1\n2\n3'; + const result = byteConverter(input, makeOptions('GB', 'MB', 0)); + expect(result).toBe('1000\n2000\n3000'); + }); + + it('ignores empty lines', () => { + const input = '1\n\n3'; + const result = byteConverter(input, makeOptions('GB', 'MB', 0)); + expect(result).toBe('1000\n\n3000'); + }); +}); diff --git a/src/pages/tools/number/byte-converter/index.tsx b/src/pages/tools/number/byte-converter/index.tsx new file mode 100644 index 0000000..d3cfb01 --- /dev/null +++ b/src/pages/tools/number/byte-converter/index.tsx @@ -0,0 +1,167 @@ +import { Box } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { byteConverter } from './service'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import SelectWithDesc from '@components/options/SelectWithDesc'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import { ToolComponentProps } from '@tools/defineTool'; +import { useTranslation } from 'react-i18next'; +import { InitialValuesType, DATA_UNITS } from './types'; + +const initialValues: InitialValuesType = { + fromUnit: 'GB', + toUnit: 'KB', + precision: 2 +}; + +export const exampleCards: CardExampleType[] = [ + { + title: 'Convert Gigabytes to Megabytes', + description: + 'This example converts a list of storage values in Gigabytes (GB) into Megabytes (MB) using the SI system. Each line represents a different storage size. The output shows the same values converted to MB with the specified precision.', + sampleText: `1 +2.5 +10 +0.75 +50`, + sampleResult: `1000 +2500 +10000 +750 +50000`, + sampleOptions: { + fromUnit: 'GB', + toUnit: 'MB', + precision: 0 + } + }, + { + title: 'Convert MiB to GiB (IEC System)', + description: + 'This example converts values in Mebibytes (MiB) to Gibibytes (GiB) using the IEC binary system. Each line of input represents a different memory size, and the output shows the conversion rounded to 2 decimal places.', + sampleText: `1024 +2048 +5120 +256 +8192`, + sampleResult: `1 +2 +5 +0.25 +8`, + sampleOptions: { + fromUnit: 'MiB', + toUnit: 'GiB', + precision: 2 + } + }, + { + title: 'Cross-System Conversion: GB to GiB', + description: + 'This example demonstrates a cross-system conversion from SI units (GB) to IEC units (GiB). Since GB and GiB are slightly different, the output shows fractional values with high precision. This is useful when comparing storage reported by operating systems vs manufacturers.', + sampleText: `1 +5 +10 +50 +100`, + sampleResult: `0.931 +4.655 +9.313 +46.566 +93.132`, + sampleOptions: { + fromUnit: 'GB', + toUnit: 'GiB', + precision: 3 + } + }, + { + title: 'Convert Bytes to Bits', + description: + 'This example converts values from Bytes (B) to Bits (b). Since 1 Byte = 8 Bits, each input value is multiplied by 8. The precision is set to 0 because the result is always an integer.', + sampleText: `1 +5 +10 +50 +100`, + sampleResult: `8 +40 +80 +400 +800`, + sampleOptions: { + fromUnit: 'B', + toUnit: 'b', + precision: 0 + } + } +]; + +export default function ByteConverter({ title }: ToolComponentProps) { + const { t } = useTranslation('number'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (optionsValues: InitialValuesType, input: any) => { + setResult(byteConverter(input, optionsValues)); + }; + + return ( + + } + resultComponent={ + + } + getGroups={({ values, updateField }) => [ + { + title: t('byteConverter.unit.title'), + component: ( + + ({ + label: unit, + value: unit + }))} + onChange={(value) => updateField('fromUnit', value)} + description={t('byteConverter.unit.from')} + /> + + ({ + label: unit, + value: unit + }))} + onChange={(value) => updateField('toUnit', value)} + description={t('byteConverter.unit.to')} + /> + + updateField('precision', Number(val))} + type={'number'} + /> + + ) + } + ]} + compute={compute} + /> + ); +} diff --git a/src/pages/tools/number/byte-converter/meta.ts b/src/pages/tools/number/byte-converter/meta.ts new file mode 100644 index 0000000..8df1c8b --- /dev/null +++ b/src/pages/tools/number/byte-converter/meta.ts @@ -0,0 +1,16 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('number', { + path: 'byte-converter', + icon: 'fluent:calculator-arrow-clockwise-24-regular', + + keywords: ['byte', 'converter'], + component: lazy(() => import('./index')), + i18n: { + name: 'number:byteConverter.title', + description: 'number:byteConverter.description', + shortDescription: 'number:byteConverter.shortDescription', + userTypes: ['generalUsers', 'developers'] + } +}); diff --git a/src/pages/tools/number/byte-converter/service.ts b/src/pages/tools/number/byte-converter/service.ts new file mode 100644 index 0000000..493cde1 --- /dev/null +++ b/src/pages/tools/number/byte-converter/service.ts @@ -0,0 +1,36 @@ +import { DataUnit, InitialValuesType, UNIT_MAP } from './types'; + +function compute( + value: number, + fromUnit: DataUnit, + toUnit: DataUnit, + precision: number +): number { + if (precision < 0 || value < 0) return 0; + const bytes = value * UNIT_MAP[fromUnit]; + const result = bytes / UNIT_MAP[toUnit]; + return Number(result.toFixed(precision)); +} + +export function byteConverter(input: string, options: InitialValuesType) { + if (!input) return ''; + + const values = input.split('\n'); + const result: string[] = []; + + const { fromUnit, toUnit, precision } = options; + + for (const elem of values) { + const trimmed = elem.trim(); + + if (!trimmed) { + result.push(''); + continue; + } + + const computed = compute(Number(trimmed), fromUnit, toUnit, precision); + result.push(String(computed)); + } + + return result.join('\n'); +} diff --git a/src/pages/tools/number/byte-converter/types.ts b/src/pages/tools/number/byte-converter/types.ts new file mode 100644 index 0000000..3b85903 --- /dev/null +++ b/src/pages/tools/number/byte-converter/types.ts @@ -0,0 +1,44 @@ +type SIUnit = 'b' | 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB'; +type IECUnit = 'b' | 'B' | 'KiB' | 'MiB' | 'GiB' | 'TiB' | 'PiB' | 'EiB'; + +export type DataUnit = SIUnit | IECUnit; + +const SI_UNITS: SIUnit[] = ['b', 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; +const IEC_UNITS: IECUnit[] = [ + 'b', + 'B', + 'KiB', + 'MiB', + 'GiB', + 'TiB', + 'PiB', + 'EiB' +]; + +export const DATA_UNITS: DataUnit[] = [ + ...SI_UNITS, + ...IEC_UNITS.filter((unit) => !SI_UNITS.includes(unit as SIUnit)) +]; + +export const UNIT_MAP: Record = { + b: 1 / 8, + B: 1, + KB: 1e3, + MB: 1e6, + GB: 1e9, + TB: 1e12, + PB: 1e15, + EB: 1e18, + KiB: 1024, + MiB: 1024 ** 2, + GiB: 1024 ** 3, + TiB: 1024 ** 4, + PiB: 1024 ** 5, + EiB: 1024 ** 6 +}; + +export type InitialValuesType = { + fromUnit: DataUnit; + toUnit: DataUnit; + precision: number; +}; diff --git a/src/pages/tools/number/index.ts b/src/pages/tools/number/index.ts index fa4facc..90d6f6f 100644 --- a/src/pages/tools/number/index.ts +++ b/src/pages/tools/number/index.ts @@ -3,12 +3,15 @@ import { tool as numberRandomNumberGenerator } from './random-number-generator/m import { tool as numberSum } from './sum/meta'; import { tool as numberGenerate } from './generate/meta'; import { tool as numberArithmeticSequence } from './arithmetic-sequence/meta'; +import { tool as numberByteConverter } from './byte-converter/meta'; import { tools as genericCalcTools } from './generic-calc/meta'; + export const numberTools = [ numberSum, numberGenerate, numberArithmeticSequence, numberRandomPortGenerator, numberRandomNumberGenerator, + numberByteConverter, ...genericCalcTools ]; From 96a6bf004493b77d31b760233ee1ddfab1faf9e7 Mon Sep 17 00:00:00 2001 From: aToO57 Date: Sat, 21 Feb 2026 17:43:06 +0100 Subject: [PATCH 034/164] Update translation.json Translation correction --- public/locales/fr/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 2e27a0c..c07a3b7 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -98,7 +98,7 @@ "title": "Faites avancer les choses rapidement avec" }, "inputFooter": { - "clear": "Clair", + "clear": "Supprimer les fichiers", "copyToClipboard": "Copier dans le presse-papiers", "importFromFile": "Importer à partir d'un fichier" }, From 5d6ef285f1b91762bd91ee3ac80d7ddf3a4574c4 Mon Sep 17 00:00:00 2001 From: aToO57 Date: Sat, 21 Feb 2026 18:01:05 +0100 Subject: [PATCH 035/164] Update json.json --- public/locales/fr/json.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/fr/json.json b/public/locales/fr/json.json index ebe4024..502a506 100644 --- a/public/locales/fr/json.json +++ b/public/locales/fr/json.json @@ -38,7 +38,7 @@ }, "useSpaces": "Utiliser les espaces", "useSpacesDescription": "Indenter la sortie avec des espaces", - "useTabs": "Utiliser les onglets", + "useTabs": "Utiliser les tabulations", "useTabsDescription": "Indenter la sortie avec des tabulations." }, "stringify": { From 7fa5b42adcbe2535ed9cc859138a202dc18a600f Mon Sep 17 00:00:00 2001 From: dhaanisi Date: Sun, 22 Feb 2026 13:36:22 +0530 Subject: [PATCH 036/164] =?UTF-8?q?Add=20remove=20file=20option=20to=20JPG?= =?UTF-8?q?=20=E2=86=92=20PNG=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/RemoveFileButton.tsx | 19 +++++++++ .../image/png/convert-jgp-to-png/index.tsx | 39 ++++++++++++++++--- src/utils/resetFileState.ts | 9 +++++ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/components/common/RemoveFileButton.tsx create mode 100644 src/utils/resetFileState.ts diff --git a/src/components/common/RemoveFileButton.tsx b/src/components/common/RemoveFileButton.tsx new file mode 100644 index 0000000..3a02aa8 --- /dev/null +++ b/src/components/common/RemoveFileButton.tsx @@ -0,0 +1,19 @@ +import { Button } from '@mui/material'; + +interface RemoveFileButtonProps { + onRemove: () => void; +} + +export default function RemoveFileButton({ onRemove }: RemoveFileButtonProps) { + return ( + + ); +} diff --git a/src/pages/tools/image/png/convert-jgp-to-png/index.tsx b/src/pages/tools/image/png/convert-jgp-to-png/index.tsx index 27fbe21..bbe7ae3 100644 --- a/src/pages/tools/image/png/convert-jgp-to-png/index.tsx +++ b/src/pages/tools/image/png/convert-jgp-to-png/index.tsx @@ -23,6 +23,11 @@ export default function ConvertJgpToPng({ title }: ToolComponentProps) { const [input, setInput] = useState(null); const [result, setResult] = useState(null); + const handleRemove = () => { + setInput(null); + setResult(null); + }; + const compute = async ( optionsValues: typeof initialValues, input: any @@ -101,12 +106,34 @@ export default function ConvertJgpToPng({ title }: ToolComponentProps) { title={title} input={input} inputComponent={ - + + { + setInput(file); + setResult(null); + }} + accept={['image/jpeg']} + title={'Input JPG'} + /> + {input && ( + + + + )} + } resultComponent={ diff --git a/src/utils/resetFileState.ts b/src/utils/resetFileState.ts new file mode 100644 index 0000000..4a66953 --- /dev/null +++ b/src/utils/resetFileState.ts @@ -0,0 +1,9 @@ +export function resetFileState( + setFile: (value: T | null) => void, + setResult?: (value: any) => void +) { + setFile(null); + if (setResult) { + setResult(null); + } +} From a4481dcfe988edbeeda8cb8ef0e1b414e13baa35 Mon Sep 17 00:00:00 2001 From: dhaanisi Date: Mon, 23 Feb 2026 21:35:36 +0530 Subject: [PATCH 037/164] Add Discord timestamp tool with copy + reverse conversion --- public/locales/en/time.json | 9 +++ .../tools/time/discord-timestamp/index.tsx | 72 +++++++++++++++++++ .../tools/time/discord-timestamp/meta.ts | 15 ++++ .../tools/time/discord-timestamp/service.ts | 16 +++++ .../tools/time/discord-timestamp/types.ts | 7 ++ src/pages/tools/time/index.ts | 4 +- 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/time/discord-timestamp/index.tsx create mode 100644 src/pages/tools/time/discord-timestamp/meta.ts create mode 100644 src/pages/tools/time/discord-timestamp/service.ts create mode 100644 src/pages/tools/time/discord-timestamp/types.ts diff --git a/public/locales/en/time.json b/public/locales/en/time.json index 1b12044..955dca3 100644 --- a/public/locales/en/time.json +++ b/public/locales/en/time.json @@ -119,5 +119,14 @@ "description": "Convert a formatted time duration (HH:MM:SS) into a decimal hour value.", "shortDescription": "Convert human time to decimal time", "longDescription": "Convert a formatted time string (HH:MM:SS or HH:MM) into its decimal-hour equivalent. Hours can be any positive number, while minutes and seconds accept values from 0-59 and can be single or double digits (e.g., '12:5' or '12:05'). This function interprets hours, minutes, and seconds, then calculates the total duration as a single decimal value. It is useful for productivity tracking, payroll calculations, time-based billing, data analysis, or any workflow that requires converting human-readable time into a numerical format that can be easily summed, compared, or processed. For example, '03:26:00' becomes 3.43 hours, and '12:5' becomes 12.08 hours." + }, + "discordTimestamp": { + "name": "Discord Timestamp Generator", + "description": "Generate Discord formatted timestamps from date and time", + "shortDescription": "Generate Discord timestamps", + "toolInfo": { + "title": "What is a Discord Timestamp", + "description": "Discord timestamps allow you to display dynamic dates and times that automatically adjust to each user's local timezone. This tool converts a date and time into the special Discord timestamp format." + } } } diff --git a/src/pages/tools/time/discord-timestamp/index.tsx b/src/pages/tools/time/discord-timestamp/index.tsx new file mode 100644 index 0000000..c0ee38e --- /dev/null +++ b/src/pages/tools/time/discord-timestamp/index.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; + +function convertInput(input: string) { + const trimmed = input.trim(); + + // Case 1: Discord timestamp → normal date + const match = trimmed.match(/^$/); + if (match) { + const unix = Number(match[1]); + const date = new Date(unix * 1000); + if (!isNaN(date.getTime())) { + return date.toISOString().replace('T', ' ').slice(0, 16); + } + } + + // Case 2: normal date → Discord timestamp + const date = new Date(trimmed); + if (isNaN(date.getTime())) return 'Invalid date/time'; + const unix = Math.floor(date.getTime() / 1000); + return ``; +} + +export default function DiscordTimestamp() { + const [value, setValue] = useState(''); + const [copied, setCopied] = useState(false); + + const result = value ? convertInput(value) : ''; + + const handleCopy = async () => { + if (!result || result === 'Invalid date/time') return; + try { + await navigator.clipboard.writeText(result); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch (e) { + console.error('Copy failed', e); + } + }; + + return ( +
+