diff --git a/src/tools/json-data-condenser/__test__/json-data-condenser.e2e.spec.ts b/src/tools/json-data-condenser/__test__/json-data-condenser.e2e.spec.ts new file mode 100644 index 00000000..ac2eea3f --- /dev/null +++ b/src/tools/json-data-condenser/__test__/json-data-condenser.e2e.spec.ts @@ -0,0 +1,106 @@ +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(); + + const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); + const outputText = await outputTextarea.inputValue(); + + expect(outputText).toContain('"status": "active"'); + expect(outputText).toContain('Alice'); + expect(outputText).toContain('David'); + expect(outputText).not.toContain('Bob'); + expect(outputText).not.toContain('Charlie'); + expect(outputText).not.toContain('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(); + }); + + test('Handles nested arrays of objects and preserves distinct structures', async ({ page }) => { + const nestedJson = JSON.stringify({ + data: { + results: [ + { + id: '1', + components: [ + { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, + { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, + { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, + { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, + ], + }, + { + id: '2', + components: [ + { content_type: 'code', content: { lang: 'js', code: 'x' } }, + { content_type: 'code', content: { lang: 'py', code: 'y' } }, + ], + }, + ], + }, + }, null, 2); + + await page.getByPlaceholder('Paste a JSON payload here...').fill(nestedJson); + await page.getByRole('button', { name: 'Condense JSON' }).click(); + + const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); + const outputText = await outputTextarea.inputValue(); + + expect(outputText).toContain('"format": "markdown"'); + expect(outputText).toContain('"video_id": "v1"'); + expect(outputText).toContain('"url": "i.jpg"'); + expect(outputText).toContain('"lang": "js"'); + + expect(outputText).not.toContain('"text": "Baz"'); + expect(outputText).not.toContain('"lang": "py"'); + }); + + test('Copies condensed JSON to clipboard', async ({ page }) => { + const validJson = JSON.stringify({ + items: [ + { id: 1, name: 'Foo' }, + { id: 2, name: 'Bar' }, + { id: 3, name: 'Baz', extra: true }, + ], + }); + + await page.getByPlaceholder('Paste a JSON payload here...').fill(validJson); + await page.getByRole('button', { name: 'Condense JSON' }).click(); + + // Click the copy button + await page.getByRole('button', { name: 'Copy' }).click(); + + // Read from clipboard + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + + // Confirm clipboard contains condensed data + expect(clipboardText).toContain('"id": 1'); + expect(clipboardText).toContain('"name": "Foo"'); + }); +}); diff --git a/src/tools/json-data-condenser/__test__/json-data-condenser.service.test.ts b/src/tools/json-data-condenser/__test__/json-data-condenser.service.test.ts new file mode 100644 index 00000000..2e9a5e70 --- /dev/null +++ b/src/tools/json-data-condenser/__test__/json-data-condenser.service.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { type JsonValue, condenseJsonStructures } from '../json-data-condenser.service'; + +const asJsonValue = (val: T): T => val; + +describe('condenseJsonStructures', () => { + it('removes duplicate object structures in an array but keeps unique ones', () => { + const input = asJsonValue({ + 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 untouched', () => { + const input = asJsonValue({ + status: 'ok', + count: 5, + success: true, + metadata: { + source: 'api', + timestamp: '2025-06-27T12:00:00Z', + }, + }); + + const output = condenseJsonStructures(input); + + expect(output).toEqual(input); + }); + + it('keeps non-object array values untouched', () => { + const input = asJsonValue([1, 2, 3, 'a', true, null]); + + const output = condenseJsonStructures(input); + + expect(output).toEqual([1, 2, 3, 'a', true, null]); + }); + + 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); + }); + + it('handles nested object arrays and preserves unique structures', () => { + const input = asJsonValue({ + data: { + results: [ + { + id: '1', + components: [ + { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, + { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, + { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, + { content_type: 'text', content: { format: 'markdown', text: 'Baz' } }, + ], + }, + { + id: '2', + components: [ + { content_type: 'code', content: { lang: 'js', code: 'x' } }, + { content_type: 'code', content: { lang: 'py', code: 'y' } }, + ], + }, + ], + }, + }); + + const output = condenseJsonStructures(input); + + expect(output).toEqual({ + data: { + results: [ + { + id: '1', + components: [ + { content_type: 'text', content: { format: 'markdown', text: 'Foo' } }, + { content_type: 'video', content: { video_id: 'v1', duration: '1:00', platform: 'yt' } }, + { content_type: 'image', content: { url: 'i.jpg', alt: 'Bar' } }, + ], + }, + { + id: '2', + components: [ + { content_type: 'code', content: { lang: 'js', code: 'x' } }, + ], + }, + ], + }, + }); + }); +}); 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 deleted file mode 100644 index 8a53a228..00000000 --- a/src/tools/json-data-condenser/json-data-condenser.e2e.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -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(); - - const outputTextarea = page.getByPlaceholder('Condensed JSON will appear here...'); - const outputText = await outputTextarea.inputValue(); - - expect(outputText).toContain('"status": "active"'); - expect(outputText).toContain('Alice'); - expect(outputText).toContain('David'); - expect(outputText).not.toContain('Bob'); - expect(outputText).not.toContain('Charlie'); - expect(outputText).not.toContain('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 deleted file mode 100644 index 0ab49a7e..00000000 --- a/src/tools/json-data-condenser/json-data-condenser.service.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type JsonValue, condenseJsonStructures } from './json-data-condenser.service'; - -const asJsonValue = (val: T): T => val; - -describe('condenseJsonStructures', () => { - it('removes duplicate object structures in an array but keeps unique ones', () => { - const input = asJsonValue({ - users: [ - { id: 1, name: 'Alice' }, - { id: 2, name: 'Bob' }, - { id: 3, name: 'Charlie' }, - { id: 4, name: 'David', email: 'david@example.com' }, // unique structure - { 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' }, // kept due to extra key - ], - status: 'active', - }); - }); - - it('keeps all non-array values untouched', () => { - const input = asJsonValue({ - status: 'ok', - count: 5, - success: true, - metadata: { - source: 'api', - timestamp: '2025-06-27T12:00:00Z', - }, - }); - - const output = condenseJsonStructures(input); - - expect(output).toEqual(input); - }); - - it('keeps non-object array values untouched', () => { - const input = asJsonValue([1, 2, 3, 'a', true, null]); - - const output = condenseJsonStructures(input); - - expect(output).toEqual([1, 2, 3, 'a', true, null]); - }); - - 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 index a952ed1e..2a350427 100644 --- a/src/tools/json-data-condenser/json-data-condenser.service.ts +++ b/src/tools/json-data-condenser/json-data-condenser.service.ts @@ -1,24 +1,57 @@ +/** + * Represents any valid JSON value, including nested objects and arrays. + */ export 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(','); +/** + * Recursively generates a deep signature string for a given JSON value. + * This signature reflects the structure and key types of the value, + * and is used to detect and eliminate redundant object structures in arrays. + * + * @param value - The JSON value to generate a structural signature for. + * @returns A normalized string representing the structure of the value. + */ +function getDeepKeySignature(value: JsonValue): string { + if (value === null || typeof value !== 'object') { + return typeof value; + } + + if (Array.isArray(value)) { + return `array<${value.map(getDeepKeySignature).join('|')}>`; + } + + const keys = Object.keys(value).sort(); + const nested = keys + .map(key => `${key}:${getDeepKeySignature((value as Record)[key])}`) + .join(','); + + return `{${nested}}`; } +/** + * Recursively condenses a JSON object by removing redundant objects + * with identical structures (key shape and nested types) in arrays. + * + * For arrays of objects, only one representative per unique structure + * is kept. Objects with additional or differing key paths are preserved. + * + * Non-array values and primitives are returned unchanged. + * + * @param data - The JSON value to condense. + * @returns A condensed version of the original JSON value. + */ export function condenseJsonStructures(data: JsonValue): JsonValue { - // Handle primitive values - if (typeof data !== 'object' || data === null) { + if (data === null || typeof data !== 'object') { return data; } - // Handle arrays 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 signature = getKeySignature(item as Record); + if (item && typeof item === 'object' && !Array.isArray(item)) { + const signature = getDeepKeySignature(item); if (!seenSignatures.has(signature)) { seenSignatures.add(signature); result.push(condenseJsonStructures(item)); @@ -32,7 +65,6 @@ export function condenseJsonStructures(data: JsonValue): JsonValue { return result; } - // Handle plain objects const result: Record = {}; for (const key in data) { result[key] = condenseJsonStructures(data[key]); diff --git a/src/tools/json-data-condenser/json-data-condenser.vue b/src/tools/json-data-condenser/json-data-condenser.vue index 47ceca05..446fe33f 100644 --- a/src/tools/json-data-condenser/json-data-condenser.vue +++ b/src/tools/json-data-condenser/json-data-condenser.vue @@ -5,6 +5,7 @@ import { condenseJsonStructures } from './json-data-condenser.service'; const rawJson = ref(''); const condensedJson = ref(''); const error = ref(null); +const copySuccess = ref(false); function condense() { error.value = null; @@ -19,6 +20,17 @@ function condense() { error.value = 'Invalid JSON input. Please fix and try again.'; } } + +async function copyToClipboard() { + try { + await navigator.clipboard.writeText(condensedJson.value); + copySuccess.value = true; + setTimeout(() => (copySuccess.value = false), 2000); + } + catch { + error.value = 'Failed to copy to clipboard.'; + } +}