From 7dde9a1e3a0e6fc763e285dac57d04213f2a4105 Mon Sep 17 00:00:00 2001 From: Nello-Moreira Date: Fri, 5 Dec 2025 19:51:25 -0400 Subject: [PATCH 01/84] 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: Sat, 10 Jan 2026 13:23:59 +0000 Subject: [PATCH 02/84] 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 03/84] 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 04/84] 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 05/84] 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 7fa5b42adcbe2535ed9cc859138a202dc18a600f Mon Sep 17 00:00:00 2001 From: dhaanisi Date: Sun, 22 Feb 2026 13:36:22 +0530 Subject: [PATCH 06/84] =?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 07/84] 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 ( +
+