diff --git a/package-lock.json b/package-lock.json index 90d6646..91491f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "cron-validator": "^1.3.1", "cronstrue": "^3.0.0", "dayjs": "^1.11.13", + "diff": "^8.0.2", + "dompurify": "^3.4.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "heic-to": "^1.4.2", @@ -3536,7 +3538,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" @@ -3597,7 +3598,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": { @@ -5634,10 +5634,9 @@ "license": "Apache-2.0" }, "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" @@ -5759,10 +5758,10 @@ } }, "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, + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -5860,7 +5859,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" @@ -7803,7 +7802,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" @@ -9002,6 +9001,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", @@ -9525,7 +9534,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" @@ -11732,7 +11740,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": { @@ -13226,7 +13234,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", diff --git a/package.json b/package.json index 427b58d..5b2cf63 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "cron-validator": "^1.3.1", "cronstrue": "^3.0.0", "dayjs": "^1.11.13", + "diff": "^8.0.2", + "dompurify": "^3.4.2", "fast-xml-parser": "^5.2.5", "formik": "^2.4.6", "heic-to": "^1.4.2", diff --git a/public/locales/en/string.json b/public/locales/en/string.json index f8fef9d..86e5bf2 100644 --- a/public/locales/en/string.json +++ b/public/locales/en/string.json @@ -328,13 +328,27 @@ "nonSpecialCharPlaceholder": "Encode non-special characters", "title": "Encoding Options" }, - "inputTitle": "Input String", - "resultTitle": "Url-escaped String", + "inputTitle": "Input String(URL-escaped)", + "resultTitle": "Output string", "toolInfo": { "description": "Load your string and it will automatically get URL-escaped.", "longDescription": "This tool URL-encodes a string. Special URL characters get converted to percent-sign encoding. This encoding is called percent-encoding because each character's numeric value gets converted to a percent sign followed by a two-digit hexadecimal value. The hex values are determined based on the character's codepoint value. For example, a space gets escaped to %20, a colon to %3a, a slash to %2f. Characters that are not special stay unchanged. In case you also need to convert non-special characters to percent-encoding, then we've also added an extra option that lets you do that. Select the encode-non-special-chars option to enable this behavior.", "shortDescription": "Quickly URL-escape a string.", "title": "String URL encoder" } + }, + "textCompare": { + "title": "Compare Texts", + "description": "Analyze two text blocks to highlight differences, including additions, deletions, and changes.", + "shortDescription": "Compare two texts and spot differences instantly", + "longDescription": "This tool helps you quickly identify differences between two versions of a text. It highlights inserted, removed, and modified content, making it ideal for reviewing edits, tracking changes, or comparing documents. You can choose the level of precision to detect differences at the word or character level depending on your needs.", + "inputTitle": "Input Text", + "resultTitle": "Highlighted differences", + "options": { + "title": "Comparison Settings", + "wordLevel": "Word level", + "charLevel": "Character level", + "levelDesc": "Choose how detailed the comparison should be. Word level highlights changes by words, while character level detects more granular differences." + } } } diff --git a/src/components/result/ToolDiffResult.tsx b/src/components/result/ToolDiffResult.tsx new file mode 100644 index 0000000..0572cd9 --- /dev/null +++ b/src/components/result/ToolDiffResult.tsx @@ -0,0 +1,113 @@ +import { Box, Typography, CircularProgress, useTheme } from '@mui/material'; +import InputHeader from '../InputHeader'; +import ResultFooter from './ResultFooter'; +import { useContext } from 'react'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import { globalInputHeight } from '../../config/uiConfig'; +import { useTranslation } from 'react-i18next'; +import { stripAndDecodeHtml } from 'utils/string'; +import DOMPurify from 'dompurify'; + +const DOMPURIFY_CONFIG = { + ALLOWED_TAGS: ['div', 'span', 'br'], + ALLOWED_ATTR: ['class'] +}; + +export default function ToolDiffResult({ + title = 'Differences', + value, + loading, + isHtml = false +}: { + title?: string; + value: string; + loading?: boolean; + isHtml?: boolean; +}) { + const theme = useTheme(); + const { t } = useTranslation(); + const { showSnackBar } = useContext(CustomSnackBarContext); + + const handleCopy = () => { + const text = isHtml ? stripAndDecodeHtml(value) : value; + navigator.clipboard + .writeText(text) + .then(() => showSnackBar(t('toolTextResult.copied'), 'success')) + .catch((err) => + showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error') + ); + }; + + const handleDownload = () => { + const mimeType = isHtml ? 'text/html' : 'text/plain'; + const blob = new Blob([value], { type: mimeType }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = isHtml ? 'diff-output.html' : 'diff-output.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => window.URL.revokeObjectURL(url), 100); + }; + + const sharedBoxSx = { + p: 2, + backgroundColor: 'background.paper', + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + fontFamily: 'monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + minHeight: '265px' + } as const; + + return ( + + + {loading ? ( + + + + {t('toolTextResult.loading')} + + + ) : isHtml ? ( + + ) : ( + {value} + )} + + + ); +} diff --git a/src/pages/tools/string/index.ts b/src/pages/tools/string/index.ts index 56ed239..9b1e62d 100644 --- a/src/pages/tools/string/index.ts +++ b/src/pages/tools/string/index.ts @@ -21,7 +21,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'; import { tool as stringUnicode } from './unicode/meta'; export const stringTools = [ @@ -46,8 +47,9 @@ export const stringTools = [ stringCensor, stringPasswordGenerator, stringEncodeUrl, - StringDecodeUrl, + stringDecodeUrl, stringUnicode, stringHiddenCharacterDetector, + stringCompare, stringSlugGenerator ]; 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..a3ee528 --- /dev/null +++ b/src/pages/tools/string/text-compare/index.tsx @@ -0,0 +1,96 @@ +import { Box } from '@mui/material'; +import { useState, useEffect } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolDiffResult from '@components/result/ToolDiffResult'; +import SelectWithDesc from '@components/options/SelectWithDesc'; +import { useTranslation } from 'react-i18next'; +import { compareTextsHtml } from './service'; +import { level, InitialValuesType } from './types'; + +const initialValues: InitialValuesType = { + level: 'word' +}; + +export default function TextCompare({ title }: ToolComponentProps) { + const { t } = useTranslation('string'); + const [inputA, setInputA] = useState(''); + const [inputB, setInputB] = useState(''); + const [level, setLevel] = useState('word'); + const [result, setResult] = useState(''); + + const compute = () => { + setResult(compareTextsHtml(inputA, inputB, level)); + }; + + useEffect(() => { + compute(); + }, [inputA, inputB, level]); + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: t('textCompare.options.title'), + component: ( + + { + updateField('level', value); + setLevel(value); + }} + description={t('textCompare.options.levelDesc')} + /> + + ) + } + ]; + + return ( + + + + + } + resultComponent={ + + } + initialValues={initialValues} + getGroups={getGroups} + setInput={() => { + setInputA(''); + setInputB(''); + setResult(''); + }} + compute={compute} + toolInfo={{ + title: t('textCompare.title'), + description: t('textCompare.longDescription') + }} + /> + ); +} 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..9132e37 --- /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: 'ic:twotone-difference', + 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..7030905 --- /dev/null +++ b/src/pages/tools/string/text-compare/service.ts @@ -0,0 +1,31 @@ +import { diffWordsWithSpace, diffChars } from 'diff'; +import { level } from './types'; +import { escapeHtml } from 'utils/string'; + +const DIFF_FN = { + word: diffWordsWithSpace, + char: diffChars +} as const; + +export function compareTextsHtml( + textA: string, + textB: string, + level: level +): string { + const diffs = DIFF_FN[level](textA, textB); + + const html = diffs + .map((part) => { + const val = escapeHtml(part.value).replace(/\n/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..44b81be --- /dev/null +++ b/src/pages/tools/string/text-compare/text-compare.service.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { compareTextsHtml } from './service'; + +describe('compareTextsHtml', () => { + it('should highlight added text', () => { + const result = compareTextsHtml('Hello world', 'Hello world here', 'word'); + expect(result).toContain(' here'); + }); + + it('should highlight removed text', () => { + const result = compareTextsHtml('Hello world here', 'Hello world', 'word'); + expect(result).toContain(' here'); + }); + + it('should highlight changes in the middle of a sentence', () => { + const result = compareTextsHtml( + 'I am in Lyon', + 'I am in Marseille', + 'word' + ); + expect(result).toContain('I am in '); + expect(result).toContain('Lyon'); + expect(result).toContain('Marseille'); + }); + + it('should return plain text for identical inputs', () => { + const result = compareTextsHtml( + 'Same text everywhere', + 'Same text everywhere', + 'word' + ); + expect(result).toContain('Same text everywhere'); + expect(result).not.toContain('diff-added'); + expect(result).not.toContain('diff-removed'); + }); + + it('should escape HTML characters', () => { + const result = compareTextsHtml( + 'Hello', + 'Hello world', + 'word' + ); + expect(result).toContain('<b>Hello'); + expect(result).toContain(' world'); + }); + + it('should escape & character', () => { + const result = compareTextsHtml('AT&T', 'AT&T rocks', 'word'); + expect(result).toContain('AT&T'); + }); + + it('should wrap result in a diff-line div', () => { + const result = compareTextsHtml('foo', 'bar', 'word'); + expect(result.startsWith('
')).toBe(true); + expect(result.endsWith('
')).toBe(true); + }); + + it('should handle empty input strings', () => { + const result = compareTextsHtml('', '', 'word'); + expect(result).toBe('
'); + }); + + it('should handle only added input', () => { + const result = compareTextsHtml('', 'New text', 'word'); + expect(result).toContain('New text'); + }); + + it('should handle only removed input', () => { + const result = compareTextsHtml('Old text', '', 'word'); + expect(result).toContain('Old text'); + }); + + it('should convert newlines to
', () => { + const result = compareTextsHtml('line1\nline2', 'line1\nline2', 'word'); + expect(result).toContain('
'); + }); + + it('should highlight individual characters with char level', () => { + const result = compareTextsHtml('color', 'colour', 'char'); + expect(result).toContain('u'); + }); +}); diff --git a/src/pages/tools/string/text-compare/types.ts b/src/pages/tools/string/text-compare/types.ts new file mode 100644 index 0000000..441f9e4 --- /dev/null +++ b/src/pages/tools/string/text-compare/types.ts @@ -0,0 +1,5 @@ +export type level = 'word' | 'char'; + +export type InitialValuesType = { + level: level; +}; diff --git a/src/utils/string.ts b/src/utils/string.ts index b5a4cc0..e874c28 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -138,3 +138,49 @@ export const getI18nNamespaceFromToolCategory = ( return 'translation'; }; + +/** + * Strips HTML tags from a string and decodes HTML entities into plain text. + * + * This function parses the provided HTML string using the browser's DOMParser, + * extracts the text content, and returns a clean, human-readable string. + * + * @param html - A string containing HTML markup + * @returns A plain text string with all HTML tags removed and entities decoded + * + * @example + * stripAndDecodeHtml('

Hello world

') + * // returns: 'Hello world' + * + * @example + * stripAndDecodeHtml('<div>Test</div>') + * // returns: '
Test
' + */ +export function stripAndDecodeHtml(html: string): string { + const doc = new DOMParser().parseFromString(html, 'text/html'); + return doc.body.textContent ?? ''; +} + +/** + * Escapes special HTML characters in a string to prevent HTML injection. + * + * Replaces the following characters with their corresponding HTML entities: + * - & → & + * - < → < + * - > → > + * - " → " + * + * @param {string} str - The input string to escape. + * @returns {string} The escaped string safe for insertion into HTML. + * + * @example + * escapeHtml('
Hello & welcome
'); + * // Returns: '<div class="test">Hello & welcome</div>' + */ +export function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}