mirror of
https://github.com/iib0011/omni-tools.git
synced 2026-04-22 21:26:23 +05:30
Merge pull request #330 from dhaanisi/feature/json-to=csv
feat: add JSON to CSV converter
This commit is contained in:
commit
0e99d0120e
8 changed files with 528 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
|
|
|||
165
src/pages/tools/json/json-to-csv/index.tsx
Normal file
165
src/pages/tools/json/json-to-csv/index.tsx
Normal file
|
|
@ -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<InitialValuesType>[] = [
|
||||
{
|
||||
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<string>('');
|
||||
const [result, setResult] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
initialValues={initialValues}
|
||||
compute={compute}
|
||||
exampleCards={exampleCards}
|
||||
inputComponent={
|
||||
<ToolCodeInput
|
||||
title={t('jsonToCsv.inputTitle')}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
language="json"
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolTextResult
|
||||
title={t('jsonToCsv.outputTitle')}
|
||||
value={result}
|
||||
extension={'csv'}
|
||||
/>
|
||||
}
|
||||
getGroups={({ values, updateField }) => [
|
||||
{
|
||||
title: t('jsonToCsv.delimiterOption'),
|
||||
component: (
|
||||
<Box>
|
||||
<TextFieldWithDesc
|
||||
description={t('jsonToCsv.options.delimiter')}
|
||||
value={values.delimiter}
|
||||
onOwnChange={(val) => updateField('delimiter', val)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('jsonToCsv.quotingOption'),
|
||||
component: (
|
||||
<Box>
|
||||
<SimpleRadio
|
||||
checked={values.quoteStrings === 'auto'}
|
||||
title={t('jsonToCsv.options.autoQuote.label')}
|
||||
description={t('jsonToCsv.options.autoQuote.description')}
|
||||
onClick={() => updateField('quoteStrings', 'auto')}
|
||||
/>
|
||||
<SimpleRadio
|
||||
checked={values.quoteStrings === 'always'}
|
||||
title={t('jsonToCsv.options.alwaysQuote.label')}
|
||||
description={t('jsonToCsv.options.alwaysQuote.description')}
|
||||
onClick={() => updateField('quoteStrings', 'always')}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('jsonToCsv.headerOption'),
|
||||
component: (
|
||||
<Box>
|
||||
<CheckboxWithDesc
|
||||
checked={values.includeHeaders}
|
||||
onChange={(value) => updateField('includeHeaders', value)}
|
||||
title={t('jsonToCsv.options.header.label')}
|
||||
description={t('jsonToCsv.options.header.description')}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
src/pages/tools/json/json-to-csv/meta.ts
Normal file
15
src/pages/tools/json/json-to-csv/meta.ts
Normal file
|
|
@ -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']
|
||||
}
|
||||
});
|
||||
172
src/pages/tools/json/json-to-csv/service.test.ts
Normal file
172
src/pages/tools/json/json-to-csv/service.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { convertJsonToCsv } from './service';
|
||||
|
||||
const defaultOptions: Parameters<typeof convertJsonToCsv>[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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
120
src/pages/tools/json/json-to-csv/service.ts
Normal file
120
src/pages/tools/json/json-to-csv/service.ts
Normal file
|
|
@ -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<string, string> = {}
|
||||
): Record<string, string> {
|
||||
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<string, string>[] {
|
||||
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');
|
||||
}
|
||||
5
src/pages/tools/json/json-to-csv/types.ts
Normal file
5
src/pages/tools/json/json-to-csv/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type InitialValuesType = {
|
||||
delimiter: string;
|
||||
includeHeaders: boolean;
|
||||
quoteStrings: 'always' | 'auto';
|
||||
};
|
||||
18
src/utils/json.ts
Normal file
18
src/utils/json.ts
Normal file
|
|
@ -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, string>[]): string[] {
|
||||
return Array.from(
|
||||
rows.reduce<Set<string>>((set, row) => {
|
||||
Object.keys(row).forEach((key) => set.add(key));
|
||||
return set;
|
||||
}, new Set())
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue