diff --git a/package-lock.json b/package-lock.json index cc72c78..835edf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@ffmpeg/util": "^0.12.2", "@imgly/background-removal": "^1.6.0", "@jimp/types": "^1.6.0", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", "@playwright/test": "^1.45.0", @@ -2104,6 +2105,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@monaco-editor/loader": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/base": { "version": "5.0.0-beta.40", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", @@ -8902,6 +8926,13 @@ "ufo": "^1.5.3" } }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT", + "peer": true + }, "node_modules/morsee": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/morsee/-/morsee-1.0.9.tgz", @@ -11271,6 +11302,12 @@ "node": ">=6" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/std-env": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", diff --git a/package.json b/package.json index 392bb6e..494c547 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@ffmpeg/util": "^0.12.2", "@imgly/background-removal": "^1.6.0", "@jimp/types": "^1.6.0", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", "@playwright/test": "^1.45.0", diff --git a/public/locales/en/json.json b/public/locales/en/json.json index 634742f..8796f27 100644 --- a/public/locales/en/json.json +++ b/public/locales/en/json.json @@ -58,5 +58,10 @@ "title": "What is JSON Validation?" }, "validJson": "✅ Valid JSON" + }, + "comparison": { + "title": "Compare JSON", + "description": "Compare two JSON objects to identify differences in structure and values.", + "shortDescription": "Find differences between two JSON objects" } } diff --git a/public/locales/hi/json.json b/public/locales/hi/json.json index 20d51e4..7f32e83 100644 --- a/public/locales/hi/json.json +++ b/public/locales/hi/json.json @@ -106,5 +106,10 @@ }, "validJson": "✅ मान्य JSON", "validationOptions": "मान्यता विकल्प" + }, + "comparison": { + "title": "JSON तुलना करें", + "description": "दो JSON वस्तुओं की संरचना और मूल्यों में अंतर की पहचान करें।", + "shortDescription": "दो JSON वस्तुओं के बीच अंतर ढूंढें" } } diff --git a/src/components/input/ToolCodeInput.tsx b/src/components/input/ToolCodeInput.tsx new file mode 100644 index 0000000..fd6df78 --- /dev/null +++ b/src/components/input/ToolCodeInput.tsx @@ -0,0 +1,73 @@ +import { Box } from '@mui/material'; +import React, { useContext, useRef } from 'react'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import InputHeader from '../InputHeader'; +import InputFooter from './InputFooter'; +import { useTranslation } from 'react-i18next'; +import Editor from '@monaco-editor/react'; +import { globalInputHeight } from '../../config/uiConfig'; + +export default function ToolCodeInput({ + value, + onChange, + title = 'Input text', + language +}: { + title?: string; + value: string; + language: string; + onChange: (value: string) => void; +}) { + const { t } = useTranslation(); + const { showSnackBar } = useContext(CustomSnackBarContext); + const fileInputRef = useRef(null); + + const handleCopy = () => { + navigator.clipboard + .writeText(value) + .then(() => showSnackBar(t('toolTextInput.copied'), 'success')) + .catch((err) => { + showSnackBar(t('toolTextInput.copyFailed', { error: err }), 'error'); + }); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result; + if (typeof text === 'string') { + onChange(text); + } + }; + reader.readAsText(file); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + return ( + + + + onChange(value ?? '')} + /> + + + + + ); +} diff --git a/src/pages/tools/json/index.ts b/src/pages/tools/json/index.ts index 04a7940..9aba4f8 100644 --- a/src/pages/tools/json/index.ts +++ b/src/pages/tools/json/index.ts @@ -5,6 +5,7 @@ import { tool as validateJson } from './validateJson/meta'; import { tool as jsonToXml } from './json-to-xml/meta'; import { tool as escapeJson } from './escape-json/meta'; import { tool as tsvToJson } from './tsv-to-json/meta'; +import { tool as jsonComparison } from './json-comparison/meta'; export const jsonTools = [ validateJson, @@ -13,5 +14,6 @@ export const jsonTools = [ jsonStringify, jsonToXml, escapeJson, - tsvToJson + tsvToJson, + jsonComparison ]; diff --git a/src/pages/tools/json/json-comparison/index.tsx b/src/pages/tools/json/json-comparison/index.tsx new file mode 100644 index 0000000..28a78ea --- /dev/null +++ b/src/pages/tools/json/json-comparison/index.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import ToolCodeInput from '@components/input/ToolCodeInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { compareJson } from './service'; +import { ToolComponentProps } from '@tools/defineTool'; +import { Grid } from '@mui/material'; + +type InitialValuesType = {}; + +const initialValues: InitialValuesType = {}; + +export default function JsonComparison({ title }: ToolComponentProps) { + const [input1, setInput1] = useState(''); + const [input2, setInput2] = useState(''); + const [result, setResult] = useState(''); + + useEffect(() => { + const compareInputs = () => { + try { + // Only compare if at least one input has content + if (input1.trim() || input2.trim()) { + const differences = compareJson( + input1 || '{}', + input2 || '{}', + 'text' + ); + setResult(differences); + } else { + setResult(''); + } + } catch (error) { + setResult( + `Error: ${ + error instanceof Error ? error.message : 'Invalid JSON format' + }` + ); + } + }; + + compareInputs(); + }, [input1, input2]); + + const handleInput1Change = (value: string | undefined) => { + setInput1(value ?? ''); + }; + + const handleInput2Change = (value: string) => { + setInput2(value); + }; + + return ( + {}} + inputComponent={ + + + + + + + + + + + + } + /> + ); +} diff --git a/src/pages/tools/json/json-comparison/meta.ts b/src/pages/tools/json/json-comparison/meta.ts new file mode 100644 index 0000000..6172c31 --- /dev/null +++ b/src/pages/tools/json/json-comparison/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('json', { + path: 'json-comparison', + icon: 'fluent:branch-compare-24-regular', + keywords: ['json', 'compare', 'diff', 'differences', 'match', 'validation'], + component: lazy(() => import('./index')), + + i18n: { + name: 'json:comparison.title', + description: 'json:comparison.description', + shortDescription: 'json:comparison.shortDescription' + } +}); diff --git a/src/pages/tools/json/json-comparison/service.test.ts b/src/pages/tools/json/json-comparison/service.test.ts new file mode 100644 index 0000000..1e673ca --- /dev/null +++ b/src/pages/tools/json/json-comparison/service.test.ts @@ -0,0 +1,64 @@ +import { compareJson } from './service'; + +describe('compareJson', () => { + it('should identify missing properties', () => { + const json1 = '{"name": "John", "age": 30}'; + const json2 = '{"name": "John"}'; + + expect(compareJson(json1, json2, 'text')).toContain( + 'age: Missing in second JSON' + ); + }); + + it('should identify value mismatches', () => { + const json1 = '{"name": "John", "age": 30}'; + const json2 = '{"name": "John", "age": 25}'; + + expect(compareJson(json1, json2, 'text')).toContain( + 'age: Mismatch: 30 != 25' + ); + }); + + it('should handle nested objects', () => { + const json1 = '{"person": {"name": "John", "age": 30}}'; + const json2 = '{"person": {"name": "Jane", "age": 30}}'; + + expect(compareJson(json1, json2, 'text')).toContain( + 'person.name: Mismatch: John != Jane' + ); + }); + + it('should return JSON format when specified', () => { + const json1 = '{"name": "John", "age": 30}'; + const json2 = '{"name": "Jane", "age": 25}'; + + const result = compareJson(json1, json2, 'json'); + const parsed = JSON.parse(result); + + expect(parsed).toHaveProperty('name'); + expect(parsed).toHaveProperty('age'); + }); + + it('should handle arrays', () => { + const json1 = '{"numbers": [1, 2, 3]}'; + const json2 = '{"numbers": [1, 2, 4]}'; + + expect(compareJson(json1, json2, 'text')).toContain( + 'numbers.2: Mismatch: 3 != 4' + ); + }); + + it('should return "No differences found" for identical JSONs', () => { + const json1 = '{"name": "John", "age": 30}'; + const json2 = '{"name": "John", "age": 30}'; + + expect(compareJson(json1, json2, 'text')).toBe('No differences found'); + }); + + it('should throw error for invalid JSON', () => { + const json1 = '{"name": "John"'; + const json2 = '{"name": "John"}'; + + expect(() => compareJson(json1, json2, 'text')).toThrow(); + }); +}); diff --git a/src/pages/tools/json/json-comparison/service.ts b/src/pages/tools/json/json-comparison/service.ts new file mode 100644 index 0000000..6141971 --- /dev/null +++ b/src/pages/tools/json/json-comparison/service.ts @@ -0,0 +1,139 @@ +const fixTrailingCommas = (json: string): string => { + // Replace trailing commas in objects and arrays with proper JSON syntax + return json + .replace(/,\s*([}\]])/g, '$1') // Remove trailing commas in objects and arrays + .replace(/,\s*\n\s*([}\]])/g, '\n$1'); // Also handle when the closing bracket is on a new line +}; + +const tryParseJSON = ( + json: string +): { valid: boolean; data?: any; error?: string } => { + if (!json.trim()) { + return { valid: true, data: {} }; + } + + try { + // Try to parse after fixing trailing commas + const fixedJson = fixTrailingCommas(json); + const data = JSON.parse(fixedJson); + return { valid: true, data }; + } catch (error) { + const errorMessage = + error instanceof SyntaxError ? error.message : 'Invalid JSON format'; + // Extract line and column info from the error message if available + const match = errorMessage.match(/at line (\d+) column (\d+)/); + if (match) { + const [, line, column] = match; + return { + valid: false, + error: `${errorMessage}\nLocation: Line ${line}, Column ${column}` + }; + } + return { + valid: false, + error: errorMessage + }; + } +}; + +export const compareJson = ( + json1: string, + json2: string, + format: 'text' | 'json' +): string => { + // Handle empty inputs + if (!json1.trim() && !json2.trim()) return ''; + + // Parse both JSON inputs + const parsed1 = tryParseJSON(json1); + const parsed2 = tryParseJSON(json2); + + // Handle parsing errors + if (!parsed1.valid || !parsed2.valid) { + const errors = []; + if (!parsed1.valid) { + errors.push(`First JSON: ${parsed1.error}`); + } + if (!parsed2.valid) { + errors.push(`Second JSON: ${parsed2.error}`); + } + throw new Error(errors.join('\n\n')); + } + + // Compare the valid JSON objects + if (format === 'json') { + const diffs = findDifferencesJSON(parsed1.data, parsed2.data); + return JSON.stringify(diffs); + } else { + const differences = findDifferencesText(parsed1.data, parsed2.data); + if (differences.length === 0) { + return 'No differences found'; + } + return differences.join('\n'); + } +}; + +const findDifferencesText = ( + obj1: any, + obj2: any, + path: string[] = [] +): string[] => { + const differences: string[] = []; + const processPath = (p: string[]): string => + p.length ? p.join('.') : 'root'; + + // Compare all keys in obj1 + for (const key in obj1) { + const currentPath = [...path, key]; + + if (!(key in obj2)) { + differences.push(`${processPath(currentPath)}: Missing in second JSON`); + continue; + } + + const value1 = obj1[key]; + const value2 = obj2[key]; + + if ( + typeof value1 === 'object' && + value1 !== null && + typeof value2 === 'object' && + value2 !== null + ) { + differences.push(...findDifferencesText(value1, value2, currentPath)); + } else if (value1 !== value2) { + differences.push( + `${processPath(currentPath)}: Mismatch: ${value1} != ${value2}` + ); + } + } + + // Check for keys in obj2 that don't exist in obj1 + for (const key in obj2) { + if (!(key in obj1)) { + const currentPath = [...path, key]; + differences.push(`${processPath(currentPath)}: Missing in first JSON`); + } + } + + return differences; +}; + +const findDifferencesJSON = (obj1: any, obj2: any): Record => { + const result: Record = {}; + + // Compare all properties + const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + + for (const key of allKeys) { + if (!(key in obj1)) { + result[key] = 'Missing in first JSON'; + } else if (!(key in obj2)) { + result[key] = 'Missing in second JSON'; + } else if (obj1[key] !== obj2[key]) { + result[key] = `Mismatch: ${obj1[key]} != ${obj2[key]}`; + } + } + + return result; +};