diff --git a/components.d.ts b/components.d.ts index 0db2d095..dbbeb8a0 100644 --- a/components.d.ts +++ b/components.d.ts @@ -77,6 +77,7 @@ declare module '@vue/runtime-core' { Ipv6UlaGenerator: typeof import('./src/tools/ipv6-ula-generator/ipv6-ula-generator.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'] JsonToYaml: typeof import('./src/tools/json-to-yaml-converter/json-to-yaml.vue')['default'] JsonViewer: typeof import('./src/tools/json-viewer/json-viewer.vue')['default'] JwtParser: typeof import('./src/tools/jwt-parser/jwt-parser.vue')['default'] diff --git a/src/tools/index.ts b/src/tools/index.ts index f451d679..319ac113 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 jsonToCsv } from './json-to-csv'; import { tool as cameraRecorder } from './camera-recorder'; import { tool as listConverter } from './list-converter'; import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter'; @@ -112,6 +113,7 @@ export const toolsByCategory: ToolCategory[] = [ crontabGenerator, jsonViewer, jsonMinify, + jsonToCsv, sqlPrettify, chmodCalculator, dockerRunToDockerComposeConverter, diff --git a/src/tools/json-to-csv/index.ts b/src/tools/json-to-csv/index.ts new file mode 100644 index 00000000..acfef02f --- /dev/null +++ b/src/tools/json-to-csv/index.ts @@ -0,0 +1,12 @@ +import { List } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'JSON to CSV', + path: '/json-to-csv', + description: 'Convert JSON to CSV with automatic header detection.', + keywords: ['json', 'to', 'csv', 'convert'], + component: () => import('./json-to-csv.vue'), + icon: List, + createdAt: new Date('2023-06-18'), +}); diff --git a/src/tools/json-to-csv/json-to-csv.e2e.spec.ts b/src/tools/json-to-csv/json-to-csv.e2e.spec.ts new file mode 100644 index 00000000..840469cf --- /dev/null +++ b/src/tools/json-to-csv/json-to-csv.e2e.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - JSON to CSV', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/json-to-csv'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('JSON to CSV - IT Tools'); + }); + + test('Provided json is converted to csv', async ({ page }) => { + await page.getByTestId('input').fill(` +[ + {'Age': 18.0, 'Salary': 20000.0, 'Gender': 'Male', 'Country': 'Germany', 'Purchased': 'N'}, + {'Age': 19.0, 'Salary': 22000.0, 'Gender': 'Female', 'Country': 'France', 'Purchased': 'N'}, +] + `); + + const generatedJson = await page.getByTestId('area-content').innerText(); + + expect(generatedJson.trim()).toEqual(` +Age,Salary,Gender,Country,Purchased +18,20000,Male,Germany,N +19,22000,Female,France,N + `.trim(), + ); + }); +}); diff --git a/src/tools/json-to-csv/json-to-csv.service.test.ts b/src/tools/json-to-csv/json-to-csv.service.test.ts new file mode 100644 index 00000000..c27bf9b6 --- /dev/null +++ b/src/tools/json-to-csv/json-to-csv.service.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { convertArrayToCsv, getHeaders } from './json-to-csv.service'; + +describe('json-to-csv service', () => { + describe('getHeaders', () => { + it('extracts all the keys from the array of objects', () => { + expect(getHeaders({ array: [{ a: 1, b: 2 }, { a: 3, c: 4 }] })).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty array if the array is empty', () => { + expect(getHeaders({ array: [] })).toEqual([]); + }); + }); + + describe('convertArrayToCsv', () => { + it('converts an array of objects to a CSV string', () => { + const array = [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b + 1,2 + 3,4" + `); + }); + + it('converts an array of objects with different keys to a CSV string', () => { + const array = [ + { a: 1, b: 2 }, + { a: 3, c: 4 }, + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b,c + 1,2, + 3,,4" + `); + }); + + it('when a value is null, it is converted to the string "null"', () => { + const array = [ + { a: null, b: 2 }, + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b + null,2" + `); + }); + + it('when a value is undefined, it is converted to an empty string', () => { + const array = [ + { a: undefined, b: 2 }, + { b: 3 }, + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b + ,2 + ,3" + `); + }); + + it('when a value contains a comma, it is wrapped in double quotes', () => { + const array = [ + { a: 'hello, world', b: 2 }, + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b + \\"hello, world\\",2" + `); + }); + + it('when a value contains a double quote, it is escaped with another double quote', () => { + const array = [ + { a: 'hello "world"', b: 2 }, + ]; + + expect(convertArrayToCsv({ array })).toMatchInlineSnapshot(` + "a,b + hello \\\\\\"world\\\\\\",2" + `); + }); + }); +}); diff --git a/src/tools/json-to-csv/json-to-csv.service.ts b/src/tools/json-to-csv/json-to-csv.service.ts new file mode 100644 index 00000000..ab3c04e7 --- /dev/null +++ b/src/tools/json-to-csv/json-to-csv.service.ts @@ -0,0 +1,35 @@ +export { getHeaders, convertArrayToCsv }; + +function getHeaders({ array }: { array: Record[] }): string[] { + const headers = new Set(); + + array.forEach(item => Object.keys(item).forEach(key => headers.add(key))); + + return Array.from(headers); +} + +function serializeValue(value: unknown): string { + if (value === null) { + return 'null'; + } + + if (value === undefined) { + return ''; + } + + const valueAsString = String(value).replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/"/g, '\\"'); + + if (valueAsString.includes(',')) { + return `"${valueAsString}"`; + } + + return valueAsString; +} + +function convertArrayToCsv({ array }: { array: Record[] }): string { + const headers = getHeaders({ array }); + + const rows = array.map(item => headers.map(header => serializeValue(item[header]))); + + return [headers.join(','), ...rows].join('\n'); +} diff --git a/src/tools/json-to-csv/json-to-csv.vue b/src/tools/json-to-csv/json-to-csv.vue new file mode 100644 index 00000000..cf154007 --- /dev/null +++ b/src/tools/json-to-csv/json-to-csv.vue @@ -0,0 +1,32 @@ + + +