From fb6257b088410fe3cab5274b4282b359fe60bad2 Mon Sep 17 00:00:00 2001 From: Bryan Liao Date: Thu, 3 Jul 2025 14:52:42 -0700 Subject: [PATCH] add json condenser tool --- components.d.ts | 1 + src/tools/index.ts | 2 + src/tools/json-data-condenser/index.ts | 12 +++ .../json-data-condenser.e2e.spec.ts | 44 ++++++++++ .../json-data-condenser.service.test.ts | 88 +++++++++++++++++++ .../json-data-condenser.service.ts | 39 ++++++++ .../json-data-condenser.vue | 61 +++++++++++++ 7 files changed, 247 insertions(+) create mode 100644 src/tools/json-data-condenser/index.ts create mode 100644 src/tools/json-data-condenser/json-data-condenser.e2e.spec.ts create mode 100644 src/tools/json-data-condenser/json-data-condenser.service.test.ts create mode 100644 src/tools/json-data-condenser/json-data-condenser.service.ts create mode 100644 src/tools/json-data-condenser/json-data-condenser.vue diff --git a/components.d.ts b/components.d.ts index e5f8d862..6608f2ba 100644 --- a/components.d.ts +++ b/components.d.ts @@ -110,6 +110,7 @@ declare module '@vue/runtime-core' { Ipv4RangeExpander: typeof import('./src/tools/ipv4-range-expander/ipv4-range-expander.vue')['default'] Ipv4SubnetCalculator: typeof import('./src/tools/ipv4-subnet-calculator/ipv4-subnet-calculator.vue')['default'] Ipv6UlaGenerator: typeof import('./src/tools/ipv6-ula-generator/ipv6-ula-generator.vue')['default'] + JsonDataCondenser: typeof import('./src/tools/json-data-condenser/json-data-condenser.vue')['default'] JsonDiff: typeof import('./src/tools/json-diff/json-diff.vue')['default'] JsonMinify: typeof import('./src/tools/json-minify/json-minify.vue')['default'] JsonToCsv: typeof import('./src/tools/json-to-csv/json-to-csv.vue')['default'] diff --git a/src/tools/index.ts b/src/tools/index.ts index 3b24024e..53a4e455 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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 jsonDataCondenser } from './json-data-condenser'; import { tool as gzipDecompressor } from './gzip-decompressor'; import { tool as emailNormalizer } from './email-normalizer'; @@ -118,6 +119,7 @@ export const toolsByCategory: ToolCategory[] = [ jsonToXml, markdownToHtml, gzipDecompressor, + jsonDataCondenser, ], }, { diff --git a/src/tools/json-data-condenser/index.ts b/src/tools/json-data-condenser/index.ts new file mode 100644 index 00000000..7c73490b --- /dev/null +++ b/src/tools/json-data-condenser/index.ts @@ -0,0 +1,12 @@ +import { ArrowsShuffle } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Json data condenser', + path: '/json-data-condenser', + description: 'Removes duplicate-shaped objects in JSON arrays to simplify the data.', + keywords: ['json', 'data', 'condenser'], + component: () => import('./json-data-condenser.vue'), + icon: ArrowsShuffle, + createdAt: new Date('2025-06-26'), +}); diff --git a/src/tools/json-data-condenser/json-data-condenser.e2e.spec.ts b/src/tools/json-data-condenser/json-data-condenser.e2e.spec.ts new file mode 100644 index 00000000..2bb1ae6c --- /dev/null +++ b/src/tools/json-data-condenser/json-data-condenser.e2e.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - Json data condenser', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/json-data-condenser'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('Json data condenser - IT Tools'); + }); + + test('Condenses valid JSON input correctly', async ({ page }) => { + const validJson = JSON.stringify({ + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David', email: 'david@example.com' }, + { id: 5, name: 'Eve' }, + ], + status: 'active', + }, null, 2); + + await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); + await page.getByRole('button', { name: 'Condense JSON' }).click(); + + await expect(page.getByText('"status": "active"')).toBeVisible(); + await expect(page.getByText('"email": "david@example.com"')).toBeVisible(); + + // Only Alice and David (with different structure) should remain + await expect(page.locator('textarea')).toContainText('Alice'); + await expect(page.locator('textarea')).toContainText('David'); + await expect(page.locator('textarea')).not.toContainText('Bob'); + await expect(page.locator('textarea')).not.toContainText('Charlie'); + await expect(page.locator('textarea')).not.toContainText('Eve'); + }); + + test('Displays error on invalid JSON input', async ({ page }) => { + await page.getByPlaceholder('Paste a JSON payload here...').fill('{ invalid json '); + await page.getByRole('button', { name: 'Condense JSON' }).click(); + + await expect(page.getByText('Invalid JSON input. Please fix and try again.')).toBeVisible(); + }); +}); diff --git a/src/tools/json-data-condenser/json-data-condenser.service.test.ts b/src/tools/json-data-condenser/json-data-condenser.service.test.ts new file mode 100644 index 00000000..5e14de43 --- /dev/null +++ b/src/tools/json-data-condenser/json-data-condenser.service.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { condenseJsonStructures } from './json-data-condenser.service'; + +describe('condenseJsonStructures', () => { + it('removes duplicate object structures in an array', () => { + const input = { + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David', email: 'david@example.com' }, + { id: 5, name: 'Eve' }, + ], + status: 'active', + }; + + const output = condenseJsonStructures(input); + + expect(output).toEqual({ + users: [ + { id: 1, name: 'Alice' }, + { id: 4, name: 'David', email: 'david@example.com' }, + ], + status: 'active', + }); + }); + + it('keeps all non-array values intact', () => { + const input = { + status: 'ok', + count: 5, + success: true, + metadata: { + source: 'api', + }, + }; + + const output = condenseJsonStructures(input); + + expect(output).toEqual(input); + }); + + it('keeps non-object array values', () => { + const input = [1, 2, 3, 'a', true, null]; + + const output = condenseJsonStructures(input); + + expect(output).toEqual([1, 2, 3, 'a', true, null]); + }); + + it('recursively condenses nested object arrays', () => { + const input = { + groups: [ + { + name: 'Group A', + users: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Charlie' }, + { id: 4, name: 'David', email: 'david@example.com' }, + { id: 5, name: 'Eve' }, + ], + }, + ], + }; + + const output = condenseJsonStructures(input); + + expect(output).toEqual({ + groups: [ + { + name: 'Group A', + users: [ + { id: 1, name: 'Alice' }, + { id: 4, name: 'David', email: 'david@example.com' }, + ], + }, + ], + }); + }); + + it('returns primitive values unchanged', () => { + expect(condenseJsonStructures(42)).toBe(42); + expect(condenseJsonStructures('hello')).toBe('hello'); + expect(condenseJsonStructures(null)).toBe(null); + expect(condenseJsonStructures(true)).toBe(true); + }); +}); diff --git a/src/tools/json-data-condenser/json-data-condenser.service.ts b/src/tools/json-data-condenser/json-data-condenser.service.ts new file mode 100644 index 00000000..56bdff0d --- /dev/null +++ b/src/tools/json-data-condenser/json-data-condenser.service.ts @@ -0,0 +1,39 @@ +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +function getKeySignature(obj: Record): string { + // Create a normalized signature string of sorted keys + return Object.keys(obj).sort().join(','); +} + +export function condenseJsonStructures(data: JsonValue): JsonValue { + if (Array.isArray(data)) { + const seenSignatures = new Set(); + const result: JsonValue[] = []; + + for (const item of data) { + if (typeof item === 'object' && item !== null && !Array.isArray(item)) { + const sig = getKeySignature(item); + if (!seenSignatures.has(sig)) { + seenSignatures.add(sig); + result.push(condenseJsonStructures(item)); + } + } + else { + // Keep non-object array items + result.push(condenseJsonStructures(item)); + } + } + + return result; + } + + if (typeof data === 'object' && data !== null) { + const result: Record = {}; + for (const key in data) { + result[key] = condenseJsonStructures(data[key]); + } + return result; + } + + return data; +} diff --git a/src/tools/json-data-condenser/json-data-condenser.vue b/src/tools/json-data-condenser/json-data-condenser.vue new file mode 100644 index 00000000..47ceca05 --- /dev/null +++ b/src/tools/json-data-condenser/json-data-condenser.vue @@ -0,0 +1,61 @@ + + +