diff --git a/public/locales/en/json.json b/public/locales/en/json.json index 95504ec..1ecbc04 100644 --- a/public/locales/en/json.json +++ b/public/locales/en/json.json @@ -46,6 +46,37 @@ "shortDescription": "Convert objects to JSON string", "title": "Stringify JSON" }, + "tsvToJson": { + "description": "Convert TSV (Tab-Separated Values) data to JSON format. Transform tabular data into structured JSON objects.", + "shortDescription": "Convert TSV to JSON format", + "title": "TSV to JSON" + }, + + "jsonToCsv": { + "description": "Convert JSON data to CSV format. Supports nested objects and arrays, automatic header detection, and configurable delimiters and quoting — ready for Excel, Google Sheets, or any spreadsheet application.", + "shortDescription": "Convert JSON to CSV format", + "title": "JSON to CSV", + "quotingOption": "String Quoting", + "delimiterOption": "Delimiter", + "headerOption": "Headers", + "inputTitle": "Input JSON", + "outputTitle": "Output CSV", + "options": { + "delimiter": "Character used to separate values in each row (e.g. comma, semicolon, tab)", + "alwaysQuote": { + "label": "Always quote", + "description": "Wrap every cell in double-quotes, regardless of its content." + }, + "autoQuote": { + "label": "Auto (recommended)", + "description": "Only quote cells that contain the delimiter, a double-quote, or a newline. Keeps output clean and minimal." + }, + "header": { + "label": "Include header row", + "description": "Use JSON keys as column names in the first row. Recommended for readability and import compatibility." + } + } + }, "validateJson": { "description": "Check if JSON is valid and well-formed.", "inputTitle": "Input JSON", diff --git a/src/pages/tools/json/index.ts b/src/pages/tools/json/index.ts index 92f53ee..b31491c 100644 --- a/src/pages/tools/json/index.ts +++ b/src/pages/tools/json/index.ts @@ -5,6 +5,7 @@ import { tool as validateJson } from './validateJson/meta'; import { tool as jsonToXml } from './json-to-xml/meta'; import { tool as escapeJson } from './escape-json/meta'; import { tool as jsonComparison } from './json-comparison/meta'; +import { tool as jsonToCsv } from './json-to-csv/meta'; export const jsonTools = [ validateJson, @@ -12,6 +13,7 @@ export const jsonTools = [ jsonMinify, jsonStringify, jsonToXml, + jsonToCsv, escapeJson, jsonComparison ]; diff --git a/src/pages/tools/json/json-to-csv/index.tsx b/src/pages/tools/json/json-to-csv/index.tsx new file mode 100644 index 0000000..0ac36ed --- /dev/null +++ b/src/pages/tools/json/json-to-csv/index.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import ToolCodeInput from '@components/input/ToolCodeInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { convertJsonToCsv } from './service'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import { ToolComponentProps } from '@tools/defineTool'; +import { Box } from '@mui/material'; +import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import SimpleRadio from '@components/options/SimpleRadio'; +import { InitialValuesType } from './types'; +import { useTranslation } from 'react-i18next'; + +const initialValues: InitialValuesType = { + delimiter: ',', + includeHeaders: true, + quoteStrings: 'auto' +}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Array of objects', + description: + 'Convert multiple JSON objects into CSV rows, one row per object.', + sampleText: `[ + { "name": "John Doe", "age": 25, "city": "New York" }, + { "name": "Jane Doe", "age": 30, "city": "Los Angeles" }, + { "name": "Bob Smith", "age": 22, "city": "Chicago" } +]`, + sampleResult: `name,age,city\nJohn Doe,25,New York\nJane Doe,30,Los Angeles\nBob Smith,22,Chicago`, + sampleOptions: { + ...initialValues + } + }, + { + title: 'Nested object (dot notation)', + description: + 'Nested keys are flattened using dot notation (e.g. address.city).', + sampleText: `[ + { + "name": "John Doe", + "age": 25, + "address": { + "street": "123 Main St", + "city": "New York", + "state": "NY", + "postalCode": "10001" + }, + "hobbies": ["reading", "running"] + } +]`, + sampleResult: `name,age,address.street,address.city,address.state,address.postalCode,hobbies[0],hobbies[1]\nJohn Doe,25,123 Main St,New York,NY,10001,reading,running`, + sampleOptions: { + ...initialValues + } + }, + { + title: 'Sparse rows', + description: + 'Missing keys are filled with empty values to keep columns aligned.', + sampleText: `[ + { "name": "Alice", "age": 30 }, + { "name": "Bob", "city": "Paris" }, + { "name": "Carol", "age": 25, "city": "Rome" } +]`, + sampleResult: `name,age,city\nAlice,30,\nBob,,Paris\nCarol,25,Rome`, + sampleOptions: { + ...initialValues + } + } +]; + +export default function JsonToCsv({ title }: ToolComponentProps) { + const { t } = useTranslation('json'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (values: InitialValuesType, input: string) => { + if (input) { + try { + const csvResult = convertJsonToCsv(input, values); + setResult(csvResult); + } catch (error) { + setResult( + `Error: ${ + error instanceof Error ? error.message : 'Invalid JSON format' + }` + ); + } + } + }; + + return ( + + } + resultComponent={ + + } + getGroups={({ values, updateField }) => [ + { + title: t('jsonToCsv.delimiterOption'), + component: ( + + updateField('delimiter', val)} + /> + + ) + }, + { + title: t('jsonToCsv.quotingOption'), + component: ( + + updateField('quoteStrings', 'auto')} + /> + updateField('quoteStrings', 'always')} + /> + + ) + }, + { + title: t('jsonToCsv.headerOption'), + component: ( + + updateField('includeHeaders', value)} + title={t('jsonToCsv.options.header.label')} + description={t('jsonToCsv.options.header.description')} + /> + + ) + } + ]} + /> + ); +} diff --git a/src/pages/tools/json/json-to-csv/meta.ts b/src/pages/tools/json/json-to-csv/meta.ts new file mode 100644 index 0000000..e71d5db --- /dev/null +++ b/src/pages/tools/json/json-to-csv/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('json', { + path: 'json-to-csv', + icon: 'material-symbols:code', + keywords: [], + component: lazy(() => import('./index')), + i18n: { + name: 'json:jsonToCsv.title', + description: 'json:jsonToCsv.description', + shortDescription: 'json:jsonToCsv.shortDescription', + userTypes: ['generalUsers', 'developers'] + } +}); diff --git a/src/pages/tools/json/json-to-csv/service.test.ts b/src/pages/tools/json/json-to-csv/service.test.ts new file mode 100644 index 0000000..f6a2011 --- /dev/null +++ b/src/pages/tools/json/json-to-csv/service.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from 'vitest'; +import { convertJsonToCsv } from './service'; + +const defaultOptions: Parameters[1] = { + delimiter: ',', + includeHeaders: true, + quoteStrings: 'auto' +}; + +describe('convertJsonToCsv', () => { + describe('basic conversion', () => { + it('converts a flat array of objects', () => { + const input = JSON.stringify([ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 } + ]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,age\r\nAlice,30\r\nBob,25` + ); + }); + + it('converts a single object', () => { + const input = JSON.stringify({ name: 'Alice', age: 30 }); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,age\r\nAlice,30` + ); + }); + + it('excludes header row when includeHeaders is false', () => { + const input = JSON.stringify([{ name: 'Alice', age: 30 }]); + + expect( + convertJsonToCsv(input, { ...defaultOptions, includeHeaders: false }) + ).toBe(`Alice,30`); + }); + }); + + describe('flattening', () => { + it('flattens nested objects using dot notation', () => { + const input = JSON.stringify([ + { name: 'Alice', address: { city: 'Paris', zip: '75000' } } + ]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,address.city,address.zip\r\nAlice,Paris,75000` + ); + }); + + it('flattens arrays using index notation', () => { + const input = JSON.stringify([ + { name: 'Alice', tags: ['admin', 'user'] } + ]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,tags[0],tags[1]\r\nAlice,admin,user` + ); + }); + + it('flattens deeply nested structures', () => { + const input = JSON.stringify([ + { user: { address: { geo: { lat: 48.8566, lng: 2.3522 } } } } + ]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `user.address.geo.lat,user.address.geo.lng\r\n48.8566,2.3522` + ); + }); + }); + + describe('sparse rows', () => { + it('fills missing keys with empty values', () => { + const input = JSON.stringify([ + { name: 'Alice', age: 30 }, + { name: 'Bob', city: 'Paris' } + ]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,age,city\r\nAlice,30,\r\nBob,,Paris` + ); + }); + + it('filters out empty objects', () => { + const input = JSON.stringify([{}, { name: 'Alice' }]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe(`name\r\nAlice`); + }); + }); + + describe('quoting', () => { + it('quotes cells containing the delimiter', () => { + const input = JSON.stringify([{ name: 'Smith, John' }]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name\r\n"Smith, John"` + ); + }); + + it('escapes double-quotes by doubling them', () => { + const input = JSON.stringify([{ name: 'He said "hello"' }]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name\r\n"He said ""hello"""` + ); + }); + + it('quotes all cells when quoteStrings is always', () => { + const input = JSON.stringify([{ name: 'Alice', age: 30 }]); + + expect( + convertJsonToCsv(input, { ...defaultOptions, quoteStrings: 'always' }) + ).toBe(`"name","age"\r\n"Alice","30"`); + }); + + it('quotes cells containing newlines', () => { + const input = JSON.stringify([{ notes: 'line1\nline2' }]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `notes\r\n"line1\nline2"` + ); + }); + }); + + describe('delimiters', () => { + it('uses semicolon as delimiter', () => { + const input = JSON.stringify([{ name: 'Alice', age: 30 }]); + + expect( + convertJsonToCsv(input, { ...defaultOptions, delimiter: ';' }) + ).toBe(`name;age\r\nAlice;30`); + }); + + it('uses tab as delimiter', () => { + const input = JSON.stringify([{ name: 'Alice', age: 30 }]); + + expect( + convertJsonToCsv(input, { ...defaultOptions, delimiter: '\t' }) + ).toBe(`name\tage\r\nAlice\t30`); + }); + }); + + describe('null and undefined values', () => { + it('converts null values to empty strings', () => { + const input = JSON.stringify([{ name: 'Alice', age: null }]); + + expect(convertJsonToCsv(input, defaultOptions)).toBe( + `name,age\r\nAlice,` + ); + }); + }); + + describe('errors', () => { + it('throws on invalid JSON', () => { + expect(() => convertJsonToCsv('invalid json', defaultOptions)).toThrow( + 'Invalid JSON input.' + ); + }); + + it('throws on bare primitive', () => { + expect(() => convertJsonToCsv('42', defaultOptions)).toThrow( + 'JSON input must be an object or array of objects, not a bare primitive.' + ); + }); + + it('throws when no data rows are found', () => { + expect(() => + convertJsonToCsv(JSON.stringify([{}, {}]), defaultOptions) + ).toThrow('No data found in the provided JSON.'); + }); + }); +}); diff --git a/src/pages/tools/json/json-to-csv/service.ts b/src/pages/tools/json/json-to-csv/service.ts new file mode 100644 index 0000000..33ddeb4 --- /dev/null +++ b/src/pages/tools/json/json-to-csv/service.ts @@ -0,0 +1,120 @@ +import { InitialValuesType } from './types'; +import { getJsonHeaders } from 'utils/json'; + +/** + * Recursively flattens any JSON value into a flat object. + * Objects → dot notation + * Arrays → index notation + */ +function flattenRecursive( + value: unknown, + prefix = '', + result: Record = {} +): Record { + if (value === null || value === undefined) { + if (prefix) result[prefix] = ''; + return result; + } + + if (typeof value !== 'object') { + if (prefix) result[prefix] = String(value); + return result; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + const newKey = prefix ? `${prefix}[${index}]` : `[${index}]`; + flattenRecursive(item, newKey, result); + }); + return result; + } + + for (const [key, val] of Object.entries(value)) { + const newKey = prefix ? `${prefix}.${key}` : key; + flattenRecursive(val, newKey, result); + } + + return result; +} + +/** + * Converts any JSON structure into row objects. + */ +function flattenToRows(json: unknown): Record[] { + if (Array.isArray(json)) { + return json.map((item) => flattenRecursive(item)); + } + + if (typeof json === 'object' && json !== null) { + return [flattenRecursive(json)]; + } + + throw new Error( + 'JSON input must be an object or array of objects, not a bare primitive.' + ); +} + +/** + * Escapes and quotes CSV cells according to options + */ +function quoteCell(value: string, options: InitialValuesType): string { + const { delimiter, quoteStrings } = options; + + const escaped = value.replace(/"/g, '""'); + + const needsQuoting = + value.includes(delimiter) || + value.includes('"') || + value.includes('\n') || + value.includes('\r'); + + if (quoteStrings === 'always') return `"${escaped}"`; + + return needsQuoting ? `"${escaped}"` : value; +} + +/** + * Converts JSON string to CSV + */ +export function convertJsonToCsv( + input: string, + options: InitialValuesType +): string { + const { delimiter, includeHeaders } = options; + + if (!delimiter) throw new Error('No CSV delimiter.'); + + let parsed: unknown; + + try { + parsed = JSON.parse(input); + } catch { + throw new Error('Invalid JSON input.'); + } + + const rows = flattenToRows(parsed).filter( + (row) => Object.keys(row).length > 0 + ); + + if (rows.length === 0) { + throw new Error('No data found in the provided JSON.'); + } + + const headers = getJsonHeaders(rows); + + const lines: string[] = []; + + if (includeHeaders) { + lines.push(headers.map((h) => quoteCell(h, options)).join(delimiter)); + } + + for (const row of rows) { + const line = headers + .map((header) => quoteCell(row[header] ?? '', options)) + .join(delimiter); + + lines.push(line); + } + + return lines.join('\r\n'); +} diff --git a/src/pages/tools/json/json-to-csv/types.ts b/src/pages/tools/json/json-to-csv/types.ts new file mode 100644 index 0000000..87b223b --- /dev/null +++ b/src/pages/tools/json/json-to-csv/types.ts @@ -0,0 +1,5 @@ +export type InitialValuesType = { + delimiter: string; + includeHeaders: boolean; + quoteStrings: 'always' | 'auto'; +}; diff --git a/src/utils/json.ts b/src/utils/json.ts new file mode 100644 index 0000000..3065f08 --- /dev/null +++ b/src/utils/json.ts @@ -0,0 +1,18 @@ +/** + * Collects all unique keys from an array of row objects, preserving first-encountered order. + * Handles sparse rows where different rows may have different keys. + * + * @param rows - Array of flattened row objects + * @returns Array of unique header strings in insertion order + * + * @example + * getJsonHeaders([{ a: '1' }, { a: '2', b: '3' }]) // → ['a', 'b'] + */ +export function getJsonHeaders(rows: Record[]): string[] { + return Array.from( + rows.reduce>((set, row) => { + Object.keys(row).forEach((key) => set.add(key)); + return set; + }, new Set()) + ); +}