From 9ef1ac28330134f826db87ba0665c1df7dc24e89 Mon Sep 17 00:00:00 2001 From: Corentin Bringer Date: Fri, 25 Jul 2025 18:32:00 +0200 Subject: [PATCH] feat: add text compare 223 --- package-lock.json | 18 +++- package.json | 1 + public/locales/en/string.json | 17 +++- src/components/result/ToolDiffResult.tsx | 91 +++++++++++++++++++ src/pages/tools/string/index.ts | 6 +- src/pages/tools/string/text-compare/index.tsx | 49 ++++++++++ src/pages/tools/string/text-compare/meta.ts | 15 +++ .../tools/string/text-compare/service.ts | 24 +++++ .../text-compare/text-compare.service.test.ts | 72 +++++++++++++++ 9 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 src/components/result/ToolDiffResult.tsx create mode 100644 src/pages/tools/string/text-compare/index.tsx create mode 100644 src/pages/tools/string/text-compare/meta.ts create mode 100644 src/pages/tools/string/text-compare/service.ts create mode 100644 src/pages/tools/string/text-compare/text-compare.service.test.ts diff --git a/package-lock.json b/package-lock.json index 835edf1..69ad16c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "cron-validator": "^1.3.1", "cronstrue": "^3.0.0", "dayjs": "^1.11.13", + "diff": "^8.0.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "i18next": "^25.3.2", @@ -5433,10 +5434,9 @@ "dev": true }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -8409,6 +8409,16 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/locize-cli/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/locize-cli/node_modules/glob": { "version": "9.3.5", "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", diff --git a/package.json b/package.json index 494c547..2c66ca3 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "cron-validator": "^1.3.1", "cronstrue": "^3.0.0", "dayjs": "^1.11.13", + "diff": "^8.0.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "i18next": "^25.3.2", diff --git a/public/locales/en/string.json b/public/locales/en/string.json index 2994890..422e267 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -280,8 +280,23 @@ "longDescription": "This tool URL-decodes a previously URL-encoded string. URL-decoding is the inverse operation of URL-encoding. All percent-encoded characters get decoded to characters that you can understand. Some of the most well known percent-encoded values are %20 for a space, %3a for a colon, %2f for a slash, and %3f for a question mark. The two digits following the percent sign are character's char code values in hex.", "title": "String URL decoder" }, - "inputTitle": "Input String(URL-escaped)", "resultTitle": "Output string" + }, + "textCompare": { + "title": "Compare Texts", + "description": "Identify differences between two text blocks, highlighting insertions, deletions, and modifications.", + "shortDescription": "Compare two texts", + "longDescription": "", + "withLabel": "Options", + "outputOptions": "Comparison Options", + "addDiffHighlight": "Highlight differences", + "addDiffHighlightDescription": "Enable syntax highlighting to better visualize changes between texts", + "ignoreWhitespace": "Ignore Whitespace", + "ignoreWhitespaceDescription": "Ignore changes that are only due to whitespace (e.g., spaces, tabs, line breaks)", + "toolInfo": { + "title": "Compare Texts", + "description": "This tool compares two text inputs and highlights the differences (insertions, deletions, and substitutions). It's useful for reviewing code changes, document revisions, or analyzing updates between versions of a file or message." + } } } diff --git a/src/components/result/ToolDiffResult.tsx b/src/components/result/ToolDiffResult.tsx new file mode 100644 index 0000000..6751217 --- /dev/null +++ b/src/components/result/ToolDiffResult.tsx @@ -0,0 +1,91 @@ +import { Box, Typography } from '@mui/material'; +import InputHeader from '../InputHeader'; +import ResultFooter from './ResultFooter'; +import { useContext } from 'react'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import { useTranslation } from 'react-i18next'; + +export default function ToolDiffResult({ + title = 'Differences', + value, + loading, + isHtml = false +}: { + title?: string; + value: string; + loading?: boolean; + isHtml?: boolean; +}) { + const { t } = useTranslation(); + const { showSnackBar } = useContext(CustomSnackBarContext); + + const handleCopy = () => { + navigator.clipboard + .writeText(value.replace(/<[^>]*>/g, '')) + .then(() => showSnackBar(t('toolTextResult.copied'), 'success')) + .catch((err) => + showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error') + ); + }; + + const handleDownload = () => { + const blob = new Blob([value], { type: 'text/html' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'diff-output.html'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + }; + + return ( + + + {loading ? ( + {t('toolTextResult.loading')} + ) : isHtml ? ( + + ) : ( + + {value} + + )} + + + ); +} diff --git a/src/pages/tools/string/index.ts b/src/pages/tools/string/index.ts index bd645f7..ffd26ca 100644 --- a/src/pages/tools/string/index.ts +++ b/src/pages/tools/string/index.ts @@ -19,7 +19,8 @@ import { tool as stringStatistic } from './statistic/meta'; 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 stringDecodeUrl } from './url-decode/meta'; +import { tool as stringCompare } from './text-compare/meta'; export const stringTools = [ stringSplit, @@ -43,5 +44,6 @@ export const stringTools = [ stringCensor, stringPasswordGenerator, stringEncodeUrl, - StringDecodeUrl + stringDecodeUrl, + stringCompare ]; diff --git a/src/pages/tools/string/text-compare/index.tsx b/src/pages/tools/string/text-compare/index.tsx new file mode 100644 index 0000000..1c57dce --- /dev/null +++ b/src/pages/tools/string/text-compare/index.tsx @@ -0,0 +1,49 @@ +import { Box } from '@mui/material'; +import { useState, useEffect } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolDiffResult from '@components/result/ToolDiffResult'; +import { useTranslation } from 'react-i18next'; +import { compareTextsHtml } from './service'; + +export default function TextCompare({ title }: ToolComponentProps) { + const { t } = useTranslation('string'); + const [inputA, setInputA] = useState(''); + const [inputB, setInputB] = useState(''); + const [result, setResult] = useState(''); + + const compute = () => { + setResult(compareTextsHtml(inputA, inputB)); + }; + + useEffect(() => { + compute(); + }, [inputA, inputB]); + + return ( + + + + + } + resultComponent={} + initialValues={{}} + getGroups={null} + setInput={() => { + setInputA(''); + setInputB(''); + setResult(''); + }} + compute={compute} + toolInfo={{ + title: t('textCompare.toolInfo.title'), + description: t('textCompare.toolInfo.description') + }} + /> + ); +} diff --git a/src/pages/tools/string/text-compare/meta.ts b/src/pages/tools/string/text-compare/meta.ts new file mode 100644 index 0000000..8329b2c --- /dev/null +++ b/src/pages/tools/string/text-compare/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('string', { + i18n: { + name: 'string:textCompare.title', + description: 'string:textCompare.description', + shortDescription: 'string:textCompare.shortDescription', + longDescription: 'string:textCompare.longDescription' + }, + path: 'text-compare', + icon: 'material-symbols-light:search', + keywords: ['text', 'compare'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/string/text-compare/service.ts b/src/pages/tools/string/text-compare/service.ts new file mode 100644 index 0000000..2b4141c --- /dev/null +++ b/src/pages/tools/string/text-compare/service.ts @@ -0,0 +1,24 @@ +import { diffWordsWithSpace } from 'diff'; + +function escapeHtml(str: string): string { + return str.replace(//g, '>'); +} + +export function compareTextsHtml(textA: string, textB: string): string { + const diffs = diffWordsWithSpace(textA, textB); + + const html = diffs + .map((part) => { + const val = escapeHtml(part.value).replace(/ /g, ' '); + if (part.added) { + return `${val}`; + } + if (part.removed) { + return `${val}`; + } + return `${val}`; + }) + .join(''); + + return `
${html}
`; +} diff --git a/src/pages/tools/string/text-compare/text-compare.service.test.ts b/src/pages/tools/string/text-compare/text-compare.service.test.ts new file mode 100644 index 0000000..c2e3578 --- /dev/null +++ b/src/pages/tools/string/text-compare/text-compare.service.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { compareTextsHtml } from './service'; + +describe('compareTextsHtml', () => { + it('should highlight added text', () => { + const textA = 'Bonjour tout le monde'; + const textB = 'Bonjour tout le monde ici'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain(' ici'); + }); + + it('should highlight removed text', () => { + const textA = 'Bonjour tout le monde ici'; + const textB = 'Bonjour tout le monde'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain(' ici'); + }); + + it('should highlight changes in the middle of a sentence', () => { + const textA = 'Je suis à Lyon'; + const textB = 'Je suis à Marseille'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain('Je suis à '); + expect(result).toContain('Lyon'); + expect(result).toContain('Marseille'); + }); + + it('should return empty diff if texts are identical', () => { + const input = 'Même texte partout'; + const result = compareTextsHtml(input, input); + expect(result).toContain('Même texte partout'); + expect(result).not.toContain('diff-added'); + expect(result).not.toContain('diff-removed'); + }); + + it('should handle HTML escaping', () => { + const textA = 'Hello'; + const textB = 'Hello world'; + + const result = compareTextsHtml(textA, textB); + expect(result).toContain('<b>Hello'); + expect(result).toContain(' world'); + }); + + it('should return a single div with class diff-line', () => { + const result = compareTextsHtml('foo', 'bar'); + expect(result.startsWith('
')).toBe(true); + expect(result.endsWith('
')).toBe(true); + }); + + it('should handle empty strings', () => { + const result = compareTextsHtml('', ''); + expect(result).toBe('
'); + }); + + it('should handle only added content', () => { + const result = compareTextsHtml('', 'Nouveau texte'); + expect(result).toContain( + 'Nouveau texte' + ); + }); + + it('should handle only removed content', () => { + const result = compareTextsHtml('Ancien texte', ''); + expect(result).toContain( + 'Ancien texte' + ); + }); +});