Merge pull request #197 from bhavesh158/json-compare

feat: add JSON comparison tool
This commit is contained in:
Ibrahima G. Coulibaly
2025-07-18 03:28:51 +01:00
committed by GitHub
10 changed files with 431 additions and 1 deletions

37
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -106,5 +106,10 @@
},
"validJson": "✅ मान्य JSON",
"validationOptions": "मान्यता विकल्प"
},
"comparison": {
"title": "JSON तुलना करें",
"description": "दो JSON वस्तुओं की संरचना और मूल्यों में अंतर की पहचान करें।",
"shortDescription": "दो JSON वस्तुओं के बीच अंतर ढूंढें"
}
}

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Box>
<InputHeader title={title || t('toolTextInput.input')} />
<Box height={globalInputHeight}>
<Editor
height={'87%'}
language={language}
value={value}
onChange={(value) => onChange(value ?? '')}
/>
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
<input
type="file"
accept="*"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</Box>
</Box>
);
}

View File

@@ -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
];

View File

@@ -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<string>('');
const [input2, setInput2] = useState<string>('');
const [result, setResult] = useState<string>('');
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 (
<ToolContent
title={title}
input={input1}
setInput={setInput1}
initialValues={initialValues}
getGroups={null}
compute={() => {}}
inputComponent={
<Grid container spacing={2}>
<Grid item xs={12} md={6} lg={4}>
<ToolCodeInput
title="First JSON"
value={input1}
onChange={handleInput1Change}
language={'json'}
/>
</Grid>
<Grid item xs={12} md={6} lg={4}>
<ToolCodeInput
title="Second JSON"
language={'json'}
value={input2}
onChange={handleInput2Change}
/>
</Grid>
<Grid item xs={12} md={12} lg={4}>
<ToolTextResult
title="Differences"
value={result}
extension={'txt'}
/>
</Grid>
</Grid>
}
/>
);
}

View File

@@ -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'
}
});

View File

@@ -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();
});
});

View File

@@ -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<string, string> => {
const result: Record<string, string> = {};
// 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;
};