feat(json-diff): add new tool to get the diff of two given JSONs

This commit is contained in:
Carsten Götzinger 2023-04-13 15:06:46 +02:00
parent 7d7cc99866
commit 80af4a3eea
9 changed files with 559 additions and 1 deletions

View file

@ -56,6 +56,7 @@
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"json5": "^2.2.3",
"jsondiffpatch-rc": "^0.4.2",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"mathjs": "^10.6.4",
@ -68,6 +69,7 @@
"plausible-tracker": "^0.3.8",
"qrcode": "^1.5.1",
"randombytes": "^2.1.0",
"sanitize-html": "^2.10.0",
"sql-formatter": "^8.2.0",
"ts-pattern": "^4.2.2",
"ua-parser-js": "^1.0.35",

77
pnpm-lock.yaml generated
View file

@ -70,6 +70,9 @@ dependencies:
json5:
specifier: ^2.2.3
version: 2.2.3
jsondiffpatch-rc:
specifier: ^0.4.2
version: 0.4.2
jwt-decode:
specifier: ^3.1.2
version: 3.1.2
@ -106,6 +109,9 @@ dependencies:
randombytes:
specifier: ^2.1.0
version: 2.1.0
sanitize-html:
specifier: ^2.10.0
version: 2.10.0
sql-formatter:
specifier: ^8.2.0
version: 8.2.0
@ -4039,6 +4045,10 @@ packages:
engines: {node: '>=8'}
dev: true
/diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
dev: false
/dijkstrajs@1.0.2:
resolution: {integrity: sha512-QV6PMaHTCNmKSeP6QoXhVTw9snc9VD8MulTT0Bd99Pacp4SS1cjcrYPgBPmibqKVtMJJfqC6XvOXgPMEEPH/fg==}
dev: false
@ -4076,9 +4086,16 @@ packages:
entities: 2.2.0
dev: true
/dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.4.0
dev: false
/domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: true
/domexception@4.0.0:
resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
@ -4094,6 +4111,13 @@ packages:
domelementtype: 2.3.0
dev: true
/domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
dependencies:
domelementtype: 2.3.0
dev: false
/domutils@2.8.0:
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
dependencies:
@ -4102,6 +4126,14 @@ packages:
domhandler: 4.3.1
dev: true
/domutils@3.0.1:
resolution: {integrity: sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==}
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dev: false
/dot-case@3.0.4:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
dependencies:
@ -4184,6 +4216,11 @@ packages:
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
engines: {node: '>=0.12'}
/entities@4.4.0:
resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==}
engines: {node: '>=0.12'}
dev: false
/errno@0.1.8:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true
@ -5349,6 +5386,15 @@ packages:
entities: 3.0.1
dev: true
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.0.1
entities: 4.4.0
dev: false
/http-proxy-agent@4.0.1:
resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==}
engines: {node: '>= 6'}
@ -5611,6 +5657,11 @@ packages:
isobject: 3.0.1
dev: false
/is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
dev: false
/is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
dev: true
@ -5924,6 +5975,15 @@ packages:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
/jsondiffpatch-rc@0.4.2:
resolution: {integrity: sha512-Y1qBHcinsSX6E24KvYEAzybrmhnyy/eg2uhablTW76oKrdY0nYeWoXRlMCvTXG0Nv/zlzGwAfb3mxg1JzitLug==}
engines: {node: '>=8.17.0'}
hasBin: true
dependencies:
diff-match-patch: 1.0.5
dev: false
bundledDependencies: []
/jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
dependencies:
@ -6698,6 +6758,10 @@ packages:
engines: {node: '>= 0.10'}
dev: true
/parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
dev: false
/parse5@6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
dev: true
@ -7422,6 +7486,17 @@ packages:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
/sanitize-html@2.10.0:
resolution: {integrity: sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==}
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 8.0.2
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.4.21
dev: false
/sax@1.2.4:
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
dev: true

View file

@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as jsonDiff } from './json-diff';
import { tool as yamlToJson } from './yaml-to-json-converter';
import { tool as jsonToYaml } from './json-to-yaml-converter';
import { tool as ipv6UlaGenerator } from './ipv6-ula-generator';
@ -102,6 +103,7 @@ export const toolsByCategory: ToolCategory[] = [
crontabGenerator,
jsonViewer,
jsonMinify,
jsonDiff,
sqlPrettify,
chmodCalculator,
dockerRunToDockerComposeConverter,

View file

@ -0,0 +1,12 @@
import { ArrowsShuffle } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'JSON diff',
path: '/json-diff',
description: 'Compares two given JSONs and build a visual comparison of them',
keywords: ['json', 'diff', 'visual'],
component: () => import('./json-diff.vue'),
icon: ArrowsShuffle,
createdAt: new Date('2023-04-12'),
});

View file

@ -0,0 +1,70 @@
<template>
<div style="flex: 0 0 100%">
<n-space style="margin: 0 auto; max-width: 600px" justify="center">
<n-form-item label="Show unchanged :" label-placement="left" label-width="160">
<n-switch v-model:value="showUnchanged" :disabled="!validInput" />
</n-form-item>
</n-space>
</div>
<div style="flex: 0 0 100%">
<n-space style="margin: 0 auto; max-width: 600px" justify="center">
<n-card title="Diff result" data-test-id="result">
<div ref="result">
<SanitizedHtml :html="diffResult" />
</div>
</n-card>
</n-space>
</div>
</template>
<script setup lang="ts">
import { computed, ref, toRefs } from 'vue';
import { withDefaultOnError } from '@/utils/defaults';
import JSON5 from 'json5';
import { DiffPatcher, formatters } from 'jsondiffpatch-rc';
import SanitizedHtml from './sanitized-html.vue';
import './styles.css';
const result = ref<HTMLDivElement | null>(null);
onMounted(() => {
showHideUnchanged();
});
const props = withDefaults(defineProps<{ left: string; right: string }>(), { left: '', right: '' });
const { left, right } = toRefs(props);
const showUnchanged = ref(true);
const leftJson = computed(() => withDefaultOnError(() => JSON5.parse(left.value), ''));
const rightJson = computed(() => withDefaultOnError(() => JSON5.parse(right.value), ''));
const diffResult = computed(() => {
if (!validInput.value) {
return '';
}
const diffPatcher = new DiffPatcher({
objectHash: function (obj, index) {
if (typeof obj._id !== 'undefined') {
return obj._id;
}
if (typeof obj.id !== 'undefined') {
return obj.id;
}
return '$$index:' + index;
},
arrays: { detectMove: true, includeValueOnMove: true },
});
const delta = diffPatcher.diff(leftJson.value, rightJson.value);
return delta === undefined ? 'both JSONs are identical' : formatters.html.format(delta, leftJson.value);
});
const validInput = computed(() => leftJson.value !== '' && rightJson.value !== '');
watch([diffResult, showUnchanged], () => showHideUnchanged(), { immediate: true });
function showHideUnchanged() {
if (result.value) {
formatters.html.showUnchanged(showUnchanged.value, result.value, 200);
}
}
</script>

View file

@ -0,0 +1,28 @@
import { test, expect } from '@playwright/test';
test.describe('Tool - Json diff', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/json-diff');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle('JSON diff - IT Tools');
});
test('Compare two identical JSONs with corresponding result message', async ({ page }) => {
const json = '{"foo":"bar","list":["item",{"key":"value"}]}';
await page.getByTestId('leftJson').fill(json);
await page.getByTestId('rightJson').fill(json);
const generatedResult = await page.getByTestId('result').innerText();
expect(generatedResult.trim()).toContain('both JSONs are identical');
});
test('Compare two different JSONs with corresponding result message', async ({ page }) => {
await page.getByTestId('leftJson').fill('{"foo":"bar","list":["item","item2",{"key":"value"}]}');
await page.getByTestId('rightJson').fill('{"foo":"bar","list":["item",{"key":"value"}]}');
await expect(page.getByTestId('result').getByRole('listitem')).toHaveCount(6);
});
});

View file

@ -0,0 +1,146 @@
<template>
<n-form-item
label="Your first json"
:feedback="leftJsonValidation.message"
:validation-status="leftJsonValidation.status"
>
<n-input
v-model:value="rawLeftJson"
placeholder="Paste your first json here..."
type="textarea"
rows="20"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
:input-props="{ 'data-test-id': 'leftJson' }"
/>
</n-form-item>
<n-form-item
label="Your json to compare"
:feedback="rightJsonValidation.message"
:validation-status="rightJsonValidation.status"
>
<n-input
v-model:value="rawRightJson"
placeholder="Paste your json to compare here..."
type="textarea"
rows="20"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
:input-props="{ 'data-test-id': 'rightJson' }"
/>
</n-form-item>
<JsonDiffResult :left="rawLeftJson" :right="rawRightJson" />
</template>
<script setup lang="ts">
import { useStorage } from '@vueuse/core';
import { useValidation } from '@/composable/validation';
import JSON5 from 'json5';
import JsonDiffResult from './json-diff-result.vue';
const rawLeftJson = useStorage(
'json-compare:left-json',
`{
"Actors": [
{
"name": "Tom Cruise",
"age": 56,
"Born At": "Syracuse, NY",
"Birthdate": "July 3, 1962",
"photo": "https://jsonformatter.org/img/tom-cruise.jpg",
"wife": null,
"weight": 67.5,
"hasChildren": true,
"hasGreyHair": false,
"children": [
"Suri",
"Isabella Jane",
"Connor"
]
},
{
"name": "Robert Downey Jr.",
"age": 53,
"Born At": "New York City, NY",
"Birthdate": "April 4, 1965",
"photo": "https://jsonformatter.org/img/Robert-Downey-Jr.jpg",
"wife": "Susan Downey",
"weight": 77.1,
"hasChildren": true,
"hasGreyHair": false,
"children": [
"Indio Falconer",
"Avri Roel",
"Exton Elias"
]
}
]
}`,
);
const rawRightJson = useStorage(
'json-compare:right-json',
`{
"Actors": [
{
"name": "Tom Cruise",
"age": 56,
"Born At": "Syracuse, NY",
"Birthdate": "July 3, 1962",
"photo": "https://jsonformatter.org/img/tom-cruise.jpg",
"wife": null,
"weight": 57.7,
"hasChildren": true,
"hasGreyHair": false,
"children": [
"Connor",
"Suri",
"Isabella Jane"
],
"favoriteFood": "Spaghetti"
},
{
"name": "Robert Downey Sr.",
"age": 53,
"Born At": "New York City, NY",
"Birthdate": "April 4, 1965",
"photo": "https://jsonformatter.org/img/Robert-Downey-Jr.jpg",
"weight": 77.1,
"hasChildren": true,
"hasGreyHair": false,
"children": [
"Indio Falconer",
"Avri Roel",
"Exton Elias"
]
}
]
}`,
);
const leftJsonValidation = useValidation({
source: rawLeftJson,
rules: [
{
validator: (v) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
],
});
const rightJsonValidation = useValidation({
source: rawRightJson,
rules: [
{
validator: (v) => v === '' || JSON5.parse(v),
message: 'Provided JSON is not valid.',
},
],
});
</script>
<style lang="less" scoped></style>

View file

@ -0,0 +1,39 @@
<template>
<span ref="block"></span>
</template>
<script lang="ts" setup>
import sanitizeHtml from 'sanitize-html';
import { Ref, ref, watch } from 'vue';
const block = ref() as Ref<HTMLSpanElement>;
const props = withDefaults(defineProps<{ html: string }>(), { html: undefined });
let options = {
allowedTags: ['div', 'ul', 'li', 'pre'],
allowedAttributes: {
div: ['class'],
ul: ['class'],
li: ['class', 'data-key'],
},
};
const onUpdateContent = () => {
if (block.value) {
block.value.innerHTML = sanitizeHtml(props.html, options);
}
};
onMounted(() => {
onUpdateContent();
});
watch(
() => props.html as string | undefined,
() => {
onUpdateContent();
},
{ immediate: true },
);
</script>

View file

@ -0,0 +1,184 @@
:root {
--jsdiff-color_fg: #888888ff;
--jsdiff-color_fg_location: #bbbbbbff;
--jsdiff-color_bg_added: #066f1988;
--jsdiff-color_bg_deleted: #ab060988;
--jsdiff-color_bg_moved: #ffffbbff;
--jsdiff-font_family: monospace;
}
.jsondiffpatch-delta {
font-family: var(--jsdiff-font_family);
margin: 0;
padding: 0 0 0 12px;
display: inline-block;
}
.jsondiffpatch-delta pre {
font-family: var(--jsdiff-font_family);
margin: 0;
padding: 0;
display: inline-block;
}
.jsondiffpatch-delta ul {
list-style-type: none;
padding: 0 0 0 20px;
margin: 0;
}
ul.jsondiffpatch-delta {
list-style-type: none;
padding: 0 0 0 20px;
margin: 0;
}
.jsondiffpatch-added .jsondiffpatch-property-name {
background: var(--jsdiff-color_bg_added);
}
.jsondiffpatch-added .jsondiffpatch-value pre {
background: var(--jsdiff-color_bg_added);
}
.jsondiffpatch-modified .jsondiffpatch-right-value {
margin-left: 5px;
}
.jsondiffpatch-modified .jsondiffpatch-right-value pre {
background: var(--jsdiff-color_bg_added);
}
.jsondiffpatch-modified .jsondiffpatch-left-value pre {
background: var(--jsdiff-color_bg_deleted);
text-decoration: line-through;
}
.jsondiffpatch-modified >.jsondiffpatch-left-value pre:after {
content: '';
}
.jsondiffpatch-modified .jsondiffpatch-value {
display: inline-block;
}
.jsondiffpatch-textdiff-added {
background: var(--jsdiff-color_bg_added);
}
.jsondiffpatch-deleted .jsondiffpatch-property-name {
background: var(--jsdiff-color_bg_deleted);
text-decoration: line-through;
}
.jsondiffpatch-deleted pre {
background: var(--jsdiff-color_bg_deleted);
text-decoration: line-through;
}
.jsondiffpatch-textdiff-deleted {
background: var(--jsdiff-color_bg_deleted);
text-decoration: line-through;
}
.jsondiffpatch-unchanged {
color: var(--jsdiff-color_fg);
transition: all 0.5s;
-webkit-transition: all 0.5s;
overflow-y: hidden;
}
.jsondiffpatch-movedestination {
color: var(--jsdiff-color_fg);
}
.jsondiffpatch-movedestination >.jsondiffpatch-value {
transition: all 0.5s;
-webkit-transition: all 0.5s;
overflow-y: hidden;
}
.jsondiffpatch-unchanged-showing .jsondiffpatch-unchanged {
max-height: 100px;
}
.jsondiffpatch-unchanged-showing .jsondiffpatch-movedestination >.jsondiffpatch-value {
max-height: 100px;
}
.jsondiffpatch-unchanged-showing .jsondiffpatch-arrow {
display: none;
}
.jsondiffpatch-unchanged-hidden .jsondiffpatch-unchanged {
max-height: 0;
}
.jsondiffpatch-unchanged-hidden .jsondiffpatch-movedestination >.jsondiffpatch-value {
max-height: 0;
display: block;
}
.jsondiffpatch-unchanged-hiding .jsondiffpatch-movedestination >.jsondiffpatch-value {
display: block;
max-height: 0;
}
.jsondiffpatch-unchanged-hiding .jsondiffpatch-unchanged {
max-height: 0;
}
.jsondiffpatch-unchanged-hiding .jsondiffpatch-arrow {
display: none;
}
.jsondiffpatch-unchanged-visible .jsondiffpatch-unchanged {
max-height: 100px;
}
.jsondiffpatch-unchanged-visible .jsondiffpatch-movedestination >.jsondiffpatch-value {
max-height: 100px;
}
.jsondiffpatch-value {
display: inline-block;
}
.jsondiffpatch-value pre:after {
content: ',';
}
.jsondiffpatch-property-name {
display: inline-block;
padding-right: 5px;
vertical-align: top;
}
.jsondiffpatch-property-name:after {
content: ': ';
}
.jsondiffpatch-child-node-type-array >.jsondiffpatch-property-name:after {
content: ': [';
}
.jsondiffpatch-child-node-type-array:after {
content: '],';
}
div.jsondiffpatch-child-node-type-array:before {
content: '[';
}
div.jsondiffpatch-child-node-type-array:after {
content: ']';
}
.jsondiffpatch-child-node-type-object >.jsondiffpatch-property-name:after {
content: ': {';
}
.jsondiffpatch-child-node-type-object:after {
content: '},';
}
div.jsondiffpatch-child-node-type-object:before {
content: '{';
}
div.jsondiffpatch-child-node-type-object:after {
content: '}';
}
li:last-child >.jsondiffpatch-value pre:after {
content: '';
}
.jsondiffpatch-moved .jsondiffpatch-value {
display: none;
}
.jsondiffpatch-moved .jsondiffpatch-moved-destination {
display: inline-block;
background: var(--jsdiff-color_bg_moved);
color: var(--jsdiff-color_fg);
}
.jsondiffpatch-moved .jsondiffpatch-moved-destination:before {
content: ' => ';
}
ul.jsondiffpatch-textdiff {
padding: 0;
}
.jsondiffpatch-textdiff-location {
color: var(--jsdiff-color_fg_location);
display: inline-block;
min-width: 60px;
}
.jsondiffpatch-textdiff-line {
display: inline-block;
}
.jsondiffpatch-textdiff-line-number:after {
content: ',';
}
.jsondiffpatch-error {
background: red;
color: white;
font-weight: bold;
}