From 9dca9c3cb8b7eed3d4ddb230fd0e8305336c0d76 Mon Sep 17 00:00:00 2001 From: Eduardo Maldonado Date: Fri, 12 Apr 2024 01:07:38 -0600 Subject: [PATCH] feat(new-tool): csv to json converter --- components.d.ts | 2 + locales/en.yml | 4 ++ locales/es.yml | 16 +++-- locales/fr.yml | 4 ++ locales/pt.yml | 4 ++ locales/uk.yml | 4 ++ src/tools/csv-to-json/csv-to-json.e2e.spec.ts | 29 ++++++++ .../csv-to-json/csv-to-json.service.test.ts | 67 +++++++++++++++++++ src/tools/csv-to-json/csv-to-json.service.ts | 41 ++++++++++++ src/tools/csv-to-json/csv-to-json.vue | 32 +++++++++ src/tools/csv-to-json/index.ts | 13 ++++ src/tools/index.ts | 2 + 12 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 src/tools/csv-to-json/csv-to-json.e2e.spec.ts create mode 100644 src/tools/csv-to-json/csv-to-json.service.test.ts create mode 100644 src/tools/csv-to-json/csv-to-json.service.ts create mode 100644 src/tools/csv-to-json/csv-to-json.vue create mode 100644 src/tools/csv-to-json/index.ts diff --git a/components.d.ts b/components.d.ts index e31119b3..0b90f7d0 100644 --- a/components.d.ts +++ b/components.d.ts @@ -58,6 +58,7 @@ declare module '@vue/runtime-core' { CrontabGenerator: typeof import('./src/tools/crontab-generator/crontab-generator.vue')['default'] CSelect: typeof import('./src/ui/c-select/c-select.vue')['default'] 'CSelect.demo': typeof import('./src/ui/c-select/c-select.demo.vue')['default'] + CsvToJson: typeof import('./src/tools/csv-to-json/csv-to-json.vue')['default'] CTable: typeof import('./src/ui/c-table/c-table.vue')['default'] 'CTable.demo': typeof import('./src/ui/c-table/c-table.demo.vue')['default'] CTextCopyable: typeof import('./src/ui/c-text-copyable/c-text-copyable.vue')['default'] @@ -159,6 +160,7 @@ declare module '@vue/runtime-core' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] RsaKeyPairGenerator: typeof import('./src/tools/rsa-key-pair-generator/rsa-key-pair-generator.vue')['default'] + SafelinkDecoder: typeof import('./src/tools/safelink-decoder/safelink-decoder.vue')['default'] SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default'] SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default'] SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default'] diff --git a/locales/en.yml b/locales/en.yml index 50d48af9..1d3c9ee4 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -104,6 +104,10 @@ tools: title: JSON to CSV description: Convert JSON to CSV with automatic header detection. + csv-to-json: + title: CSV to JSON + description: Convert CSV to JSON with automatic header detection. + camera-recorder: title: Camera recorder description: Take a picture or record a video from your webcam or camera. diff --git a/locales/es.yml b/locales/es.yml index b87502fc..df4556e4 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -4,8 +4,8 @@ home: favoriteTools: 'Tus herramientas favoritas' allTools: 'Todas las herramientas' subtitle: 'Herramientas practicas para desarrolladores' - toggleMenu: 'Toggle menu' - home: Home + toggleMenu: 'Alternar menú' + home: Inicio uiLib: 'UI Lib' support: 'Apoyar el desarrollo de IT-Tools' buyMeACoffee: 'Buy me a coffee' @@ -48,7 +48,7 @@ about: notFound: '404 Not Found' sorry: 'Lo sentimos, esta página no parece existir' maybe: 'Tal vez el caché esté haciendo cosas raras, ¿probamos a refrescar forzosamente?' - backHome: 'Back home' + backHome: 'Volver al inicio' favoriteButton: remove: 'Quitar de favoritos' add: 'Añadir a favoritos' @@ -60,12 +60,16 @@ tools: categories: favorite-tools: 'Tus herramientas favoritas' crypto: Crypto - converter: Converter + converter: Conversor web: Web - images and videos: 'Images & Videos' + images and videos: 'Imágenes y vídeos' development: Development network: Network math: Math measurement: Measurement text: Text - data: Data \ No newline at end of file + data: Data + + csv-to-json: + title: CSV a JSON + description: Convierte CSV a JSON con detección automática de cabeceras. diff --git a/locales/fr.yml b/locales/fr.yml index 35c7df2e..a0e13063 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -79,3 +79,7 @@ tools: copied: Le token a été copié length: Longueur tokenPlaceholder: Le token... + + csv-to-json: + title: CSV vers JSON + description: Convertit les fichiers CSV en JSON avec détection automatique des en-têtes. diff --git a/locales/pt.yml b/locales/pt.yml index 96fdaed4..65c41c08 100644 --- a/locales/pt.yml +++ b/locales/pt.yml @@ -69,3 +69,7 @@ tools: measurement: 'Medidas' text: 'Texto' data: 'Dados' + + csv-to-json: + title: CSV para JSON + description: Converte CSV para JSON com detecção automática de cabeçalhos. diff --git a/locales/uk.yml b/locales/uk.yml index 2d28f157..a627555a 100644 --- a/locales/uk.yml +++ b/locales/uk.yml @@ -69,3 +69,7 @@ tools: measurement: Вимірювання text: Текст data: Дані + + csv-to-json: + title: 'CSV в JSON' + description: 'Конвертуйте CSV в JSON з автоматичним визначенням заголовків.' diff --git a/src/tools/csv-to-json/csv-to-json.e2e.spec.ts b/src/tools/csv-to-json/csv-to-json.e2e.spec.ts new file mode 100644 index 00000000..6ae90e9f --- /dev/null +++ b/src/tools/csv-to-json/csv-to-json.e2e.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Tool - CSV to JSON', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/csv-to-json'); + }); + + test('Has correct title', async ({ page }) => { + await expect(page).toHaveTitle('CSV to JSON - IT Tools'); + }); + + test('Provided csv is converted to json', async ({ page }) => { + await page.getByTestId('input').fill(` +Age,Salary,Gender,Country,Purchased +18,20000,Male,Germany,N +19,22000,Female,France,N + `); + + const generatedJson = await page.getByTestId('area-content').innerText(); + + expect(generatedJson.trim()).toEqual(` +[ + {"Age": "18", "Salary": "20000", "Gender": "Male", "Country": "Germany", "Purchased": "N"}, + {"Age": "19", "Salary": "22000", "Gender": "Female", "Country": "France", "Purchased": "N"} +] + `.trim(), + ); + }); +}); diff --git a/src/tools/csv-to-json/csv-to-json.service.test.ts b/src/tools/csv-to-json/csv-to-json.service.test.ts new file mode 100644 index 00000000..932b334a --- /dev/null +++ b/src/tools/csv-to-json/csv-to-json.service.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { convertCsvToArray, getHeaders } from './csv-to-json.service'; + +describe('csv-to-json service', () => { + describe('getHeaders', () => { + it('extracts all the keys from the first line of the CSV', () => { + expect(getHeaders('a,b,c\n1,2,3\n4,5,6')).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty array if the CSV is empty', () => { + expect(getHeaders('')).toEqual([]); + }); + }); + + describe('convertCsvToArray', () => { + it('converts a CSV string to an array of objects', () => { + const csv = 'a,b\n1,2\n3,4'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: '1', b: '2' }, + { a: '3', b: '4' }, + ]); + }); + + it('converts a CSV string with different keys to an array of objects', () => { + const csv = 'a,b,c\n1,2,\n3,,4'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: '1', b: '2', c: undefined }, + { a: '3', b: undefined, c: '4' }, + ]); + }); + + it('when a value is "null", it is converted to null', () => { + const csv = 'a,b\nnull,2'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: null, b: '2' }, + ]); + }); + + it('when a value is empty, it is converted to undefined', () => { + const csv = 'a,b\n,2\n,3'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: undefined, b: '2' }, + { a: undefined, b: '3' }, + ]); + }); + + it('when a value is wrapped in double quotes, the quotes are removed', () => { + const csv = 'a,b\n"hello, world",2'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: 'hello, world', b: '2' }, + ]); + }); + + it('when a value contains an escaped double quote, the escape character is removed', () => { + const csv = 'a,b\nhello \\"world\\",2'; + + expect(convertCsvToArray(csv)).toEqual([ + { a: 'hello "world"', b: '2' }, + ]); + }); + }); +}); diff --git a/src/tools/csv-to-json/csv-to-json.service.ts b/src/tools/csv-to-json/csv-to-json.service.ts new file mode 100644 index 00000000..a6a343fd --- /dev/null +++ b/src/tools/csv-to-json/csv-to-json.service.ts @@ -0,0 +1,41 @@ +export { getHeaders, convertCsvToArray }; + +function getHeaders(csv: string): string[] { + if (csv.trim() === '') { + return []; + } + + const firstLine = csv.split('\n')[0]; + return firstLine.split(/[,;]/).map(header => header.trim()); +} +function deserializeValue(value: string): unknown { + if (value === 'null') { + return null; + } + + if (value === '') { + return undefined; + } + + const valueAsString = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\"/g, '"'); + + if (valueAsString.startsWith('"') && valueAsString.endsWith('"')) { + return valueAsString.slice(1, -1); + } + + return valueAsString; +} + +function convertCsvToArray(csv: string): Record[] { + const lines = csv.split('\n'); + const headers = getHeaders(csv); + + return lines.slice(1).map(line => { + // Split on comma or semicolon not within quotes + const data = line.split(/[,;](?=(?:(?:[^"]*"){2})*[^"]*$)/).map(value => value.trim()); + return headers.reduce((obj, header, index) => { + obj[header] = deserializeValue(data[index]); + return obj; + }, {} as Record); + }); +} diff --git a/src/tools/csv-to-json/csv-to-json.vue b/src/tools/csv-to-json/csv-to-json.vue new file mode 100644 index 00000000..4a55b06d --- /dev/null +++ b/src/tools/csv-to-json/csv-to-json.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/tools/csv-to-json/index.ts b/src/tools/csv-to-json/index.ts new file mode 100644 index 00000000..0d016d3b --- /dev/null +++ b/src/tools/csv-to-json/index.ts @@ -0,0 +1,13 @@ +import { ArrowsShuffle } from '@vicons/tabler'; +import { defineTool } from '../tool'; +import { translate } from '@/plugins/i18n.plugin'; + +export const tool = defineTool({ + name: translate('tools.csv-to-json.title'), + path: '/csv-to-json', + description: translate('tools.csv-to-json.description'), + keywords: ['csv', 'to', 'json', 'convert'], + component: () => import('./csv-to-json.vue'), + icon: ArrowsShuffle, + createdAt: new Date('2024-04-12'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index aa861c93..4787ff8c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -21,6 +21,7 @@ import { tool as jsonToToml } from './json-to-toml'; import { tool as tomlToYaml } from './toml-to-yaml'; import { tool as tomlToJson } from './toml-to-json'; import { tool as jsonToCsv } from './json-to-csv'; +import { tool as csvToJson } from './csv-to-json'; import { tool as cameraRecorder } from './camera-recorder'; import { tool as listConverter } from './list-converter'; import { tool as phoneParserAndFormatter } from './phone-parser-and-formatter'; @@ -143,6 +144,7 @@ export const toolsByCategory: ToolCategory[] = [ jsonViewer, jsonMinify, jsonToCsv, + csvToJson, sqlPrettify, chmodCalculator, dockerRunToDockerComposeConverter,