feat: insert csv columns

This commit is contained in:
Chesterkxng
2025-05-24 02:50:38 +02:00
parent a53dfc7c15
commit a2f63664b0
8 changed files with 539 additions and 3 deletions

View File

@@ -0,0 +1,46 @@
import { Box, TextField, TextFieldProps } from '@mui/material';
import Typography from '@mui/material/Typography';
import React from 'react';
type OwnProps = {
description?: string;
value: string;
onOwnChange: (value: string) => void;
placeholder?: string;
rows?: number;
};
const TextareaWithDesc = ({
description,
value,
onOwnChange,
placeholder,
minRows = 3,
...props
}: TextFieldProps & OwnProps) => {
return (
<Box>
<TextField
multiline
minRows={minRows}
placeholder={placeholder}
value={value}
onChange={(event) => onOwnChange(event.target.value)}
sx={{
backgroundColor: 'background.paper',
'& .MuiInputBase-root': {
overflow: 'hidden' // ✨ Prevent scrollbars
}
}}
{...props}
/>
{description && (
<Typography fontSize={12} mt={1}>
{description}
</Typography>
)}
</Box>
);
};
export default TextareaWithDesc;

View File

@@ -1,3 +1,4 @@
import { tool as insertCsvColumns } from './insert-csv-columns/meta';
import { tool as transposeCsv } from './transpose-csv/meta'; import { tool as transposeCsv } from './transpose-csv/meta';
import { tool as findIncompleteCsvRecords } from './find-incomplete-csv-records/meta'; import { tool as findIncompleteCsvRecords } from './find-incomplete-csv-records/meta';
import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta'; import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta';
@@ -17,5 +18,6 @@ export const csvTools = [
csvToYaml, csvToYaml,
ChangeCsvDelimiter, ChangeCsvDelimiter,
findIncompleteCsvRecords, findIncompleteCsvRecords,
transposeCsv transposeCsv,
insertCsvColumns
]; ];

View File

@@ -0,0 +1,283 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { GetGroupsType } from '@components/options/ToolOptions';
import { CardExampleType } from '@components/examples/ToolExamples';
import { main } from './service';
import { getCsvHeaders } from '@utils/csv';
import { InitialValuesType } from './types';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import TextareaWithDesc from '@components/options/TextareaWithDesc';
import SelectWithDesc from '@components/options/SelectWithDesc';
const initialValues: InitialValuesType = {
csvToInsert: '',
commentCharacter: '#',
separator: ',',
quoteChar: '"',
insertingPosition: 'append',
customFill: false,
customFillValue: '',
customPostionOptions: 'headerName',
headerName: '',
rowNumber: 1
};
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Add One Column to a CSV File',
description:
'In this example, we insert a column with the title "city" into a CSV file that already contains two other columns with titles "name" and "age". The new column consists of three values: "city", "dallas", and "houston", corresponding to the height of the input CSV data. The value "city" is the header value (appearing on the first row) and values "dallas" and "houston" are data values (appearing on rows two and three). We specify the position of the new column by an ordinal number and set it to 1 in the options. This value indicates that the new "city" column should be placed after the first column.',
sampleText: `name,age
john,25
emma,22`,
sampleResult: `name,city,age
john,dallas,25
emma,houston,22`,
sampleOptions: {
csvToInsert: `city
dallas
houston`,
commentCharacter: '#',
separator: ',',
quoteChar: '"',
insertingPosition: 'custom',
customFill: true,
customFillValue: 'k',
customPostionOptions: 'rowNumber',
headerName: '',
rowNumber: 1
}
},
{
title: 'Append Multiple columns by header Name',
description:
'In this example, we append two data columns to the end of CSV data. The input CSV has data about cars, including the "Brand" and "Model" of the car. We now add two more columns at the end: "Year" and "Price". To do this, we enter these two data columns in the comma-separated format in the "New Column" option, and to quickly add the new columns to the end of the CSV, then we specify the name of the header they should be put after.',
sampleText: `Brand,Model
Toyota,Camry
Ford,Mustang
Honda,Accord
Chevrolet,Malibu`,
sampleResult: `Brand,Model,Year,Price
Toyota,Camry,2022,25000
Ford,Mustang,2021,35000
Honda,Accord,2022,27000
Chevrolet,Malibu,2021,28000`,
sampleOptions: {
csvToInsert: `Year,Price
2022,25000
2021,35000
2022,27000
2021,28000`,
commentCharacter: '#',
separator: ',',
quoteChar: '"',
insertingPosition: 'custom',
customFill: false,
customFillValue: 'x',
customPostionOptions: 'headerName',
headerName: 'Model',
rowNumber: 1
}
},
{
title: 'Append Multiple columns',
description:
'In this example, we append two data columns to the end of CSV data. The input CSV has data about cars, including the "Brand" and "Model" of the car. We now add two more columns at the end: "Year" and "Price". To do this, we enter these two data columns in the comma-separated format in the "New Column" option, and to quickly add the new columns to the end of the CSV, then we select append.',
sampleText: `Brand,Model
Toyota,Camry
Ford,Mustang
Honda,Accord
Chevrolet,Malibu`,
sampleResult: `Brand,Model,Year,Price
Toyota,Camry,2022,25000
Ford,Mustang,2021,35000
Honda,Accord,2022,27000
Chevrolet,Malibu,2021,28000`,
sampleOptions: {
csvToInsert: `Year,Price
2022,25000
2021,35000
2022,27000
2021,28000`,
commentCharacter: '#',
separator: ',',
quoteChar: '"',
insertingPosition: 'append',
customFill: false,
customFillValue: 'x',
customPostionOptions: 'rowNumber',
headerName: '',
rowNumber: 1
}
}
];
export default function InsertCsvColumns({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (values: InitialValuesType, input: string) => {
setResult(main(input, values));
};
const headers = getCsvHeaders(input);
const headerOptions =
headers.length > 0
? headers.map((item) => ({
label: `${item}`,
value: item
}))
: [];
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'CSV to insert',
component: (
<Box>
<TextareaWithDesc
value={values.csvToInsert}
onOwnChange={(val) => updateField('csvToInsert', val)}
title="CSV separator"
description={`Enter one or more columns you want to insert into the CSV.
the character used to delimit columns has to be the same with the one in the CSV input file.
Ps: Blank lines will be ignored`}
/>
</Box>
)
},
{
title: 'CSV Options',
component: (
<Box>
<TextFieldWithDesc
value={values.separator}
onOwnChange={(val) => updateField('separator', val)}
description={
'Enter the character used to delimit columns in the CSV input file.'
}
/>
<TextFieldWithDesc
value={values.quoteChar}
onOwnChange={(val) => updateField('quoteChar', val)}
description={
'Enter the quote character used to quote the CSV input fields.'
}
/>
<TextFieldWithDesc
value={values.commentCharacter}
onOwnChange={(val) => updateField('commentCharacter', val)}
description={
'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.'
}
/>
<SelectWithDesc
selected={values.customFill}
options={[
{ label: 'Fill With Empty Values', value: false },
{ label: 'Fill With Customs Values', value: true }
]}
onChange={(value) => {
updateField('customFill', value);
if (!value) {
updateField('customFillValue', ''); // Reset custom fill value
}
}}
description={
'If the input CSV file is incomplete (missing values), then add empty fields or custom symbols to records to make a well-formed CSV?'
}
/>
{values.customFill && (
<TextFieldWithDesc
value={values.customFillValue}
onOwnChange={(val) => updateField('customFillValue', val)}
description={
'Use this custom value to fill in missing fields. (Works only with "Custom Values" mode above.)'
}
/>
)}
</Box>
)
},
{
title: 'Position Options',
component: (
<Box>
<SelectWithDesc
selected={values.insertingPosition}
options={[
{ label: 'Prepend columns', value: 'prepend' },
{ label: 'Append columns', value: 'append' },
{ label: 'Custom position', value: 'custom' }
]}
onChange={(value) => updateField('insertingPosition', value)}
description={'Specify where to insert the columns in the CSV file.'}
/>
{values.insertingPosition === 'custom' && (
<SelectWithDesc
selected={values.customPostionOptions}
options={[
{ label: 'Header name', value: 'headerName' },
{ label: 'Position ', value: 'rowNumber' }
]}
onChange={(value) => updateField('customPostionOptions', value)}
description={
'Select the method to insert the columns in the CSV file.'
}
/>
)}
{values.insertingPosition === 'custom' &&
values.customPostionOptions === 'headerName' && (
<SelectWithDesc
selected={values.headerName}
options={headerOptions}
onChange={(value) => updateField('headerName', value)}
description={
'Header of the column you want to insert columns after.'
}
/>
)}
{values.insertingPosition === 'custom' &&
values.customPostionOptions === 'rowNumber' && (
<TextFieldWithDesc
value={values.rowNumber}
onOwnChange={(val) => updateField('rowNumber', Number(val))}
inputProps={{ min: 1, max: headers.length }}
type="number"
description={
'Number of the column you want to insert columns after.'
}
/>
)}
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolTextInput value={input} title="Input CSV" onChange={setInput} />
}
resultComponent={<ToolTextResult title="Output CSV" value={result} />}
initialValues={initialValues}
exampleCards={exampleCards}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { main } from './service';
import type { InitialValuesType } from './types';
describe('main function', () => {
const baseOptions: Omit<InitialValuesType, 'csvToInsert'> = {
commentCharacter: '#',
separator: ',',
quoteChar: '"',
insertingPosition: 'append',
customFill: false,
customFillValue: '',
customPostionOptions: 'headerName',
headerName: 'age',
rowNumber: 1
};
const originalCsv = `name,age\nAlice,30\nBob,25`;
it('should return empty string if input or csvToInsert is empty', () => {
expect(main('', { ...baseOptions, csvToInsert: '' })).toBe('');
expect(main(originalCsv, { ...baseOptions, csvToInsert: '' })).toBe('');
});
it('should append columns at the end', () => {
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
const result = main(originalCsv, {
...baseOptions,
insertingPosition: 'append',
csvToInsert
});
expect(result).toBe(
'name,age,email\nAlice,30,alice@mail.com\nBob,25,bob@mail.com'
);
});
it('should prepend columns at the beginning', () => {
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
const result = main(originalCsv, {
...baseOptions,
insertingPosition: 'prepend',
csvToInsert
});
expect(result).toBe(
'email,name,age\nalice@mail.com,Alice,30\nbob@mail.com,Bob,25'
);
});
it('should insert columns after a header name', () => {
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
const result = main(originalCsv, {
...baseOptions,
insertingPosition: 'custom',
customPostionOptions: 'headerName',
headerName: 'name',
csvToInsert
});
expect(result).toBe(
'name,email,age\nAlice,alice@mail.com,30\nBob,bob@mail.com,25'
);
});
it('should insert columns after a row number (column index)', () => {
const csvToInsert = `email\nalice@mail.com\nbob@mail.com`;
const result = main(originalCsv, {
...baseOptions,
insertingPosition: 'custom',
customPostionOptions: 'rowNumber',
rowNumber: 0,
csvToInsert
});
expect(result).toBe(
'name,email,age\nAlice,alice@mail.com,30\nBob,bob@mail.com,25'
);
});
it('should handle missing values and fill with empty string by default', () => {
const csv = `name\nAlice\nBob`;
const csvToInsert = `email\nalice@mail.com\n`; // second row is missing
const result = main(csv, {
...baseOptions,
insertingPosition: 'append',
csvToInsert
});
expect(result).toBe('name,email\nAlice,alice@mail.com\nBob,');
});
});

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('csv', {
name: 'Insert CSV columns',
path: 'insert-csv-columns',
icon: 'hugeicons:column-insert',
description:
'Just upload your CSV file in the form below, paste the new column in the options, and it will automatically get inserted in your CSV. In the tool options, you can also specify more than one column to insert, set the insertion position, and optionally skip the empty and comment lines.',
shortDescription:
'Quickly insert one or more new columns anywhere in a CSV file.',
keywords: ['insert', 'csv', 'columns', 'append', 'prepend'],
longDescription: '',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,81 @@
import { InitialValuesType } from './types';
import { splitCsv } from '@utils/csv';
import { transpose, normalizeAndFill } from '@utils/array';
export function main(input: string, options: InitialValuesType): string {
if (!input || !options.csvToInsert) return '';
// Parse input CSV and insert CSV
const inputRows = splitCsv(
input,
true,
options.commentCharacter,
true,
options.separator,
options.quoteChar
);
const filledRows = options.customFill
? normalizeAndFill(inputRows, options.customFillValue)
: normalizeAndFill(inputRows, '');
let columns = transpose(filledRows);
const csvToInsertRows = splitCsv(
options.csvToInsert,
true,
options.commentCharacter,
true,
options.separator,
options.quoteChar
);
const filledCsvToInsertRows = options.customFill
? normalizeAndFill(csvToInsertRows, options.customFillValue)
: normalizeAndFill(csvToInsertRows, '');
const columnsToInsert = transpose(filledCsvToInsertRows);
switch (options.insertingPosition) {
case 'prepend':
columns = [...columnsToInsert, ...columns];
break;
case 'append':
columns = [...columns, ...columnsToInsert];
break;
case 'custom':
if (options.customPostionOptions === 'headerName') {
const headerName = options.headerName;
const index = filledRows[0].indexOf(headerName);
if (index !== -1) {
columns = [
...columns.slice(0, index + 1),
...columnsToInsert,
...columns.slice(index + 1)
];
} // else: keep original columns
} else if (options.customPostionOptions === 'rowNumber') {
const index = options.rowNumber;
if (index >= 0 && index <= columns.length) {
columns = [
...columns.slice(0, index),
...columnsToInsert,
...columns.slice(index)
];
} // else: keep original columns
}
break;
default:
// no-op, keep original columns
break;
}
// Transpose back to rows
const normalizedColumns = normalizeAndFill(columns, options.customFillValue);
const finalRows = transpose(normalizedColumns);
return finalRows.map((row) => row.join(options.separator)).join('\n');
}

View File

@@ -0,0 +1,15 @@
export type insertingPosition = 'prepend' | 'append' | 'custom';
export type customPostion = 'headerName' | 'rowNumber';
export type InitialValuesType = {
csvToInsert: string;
separator: string;
quoteChar: string;
commentCharacter: string;
customFill: boolean;
customFillValue: string;
insertingPosition: insertingPosition;
customPostionOptions: customPostion;
headerName: string;
rowNumber: number;
};

View File

@@ -12,10 +12,17 @@ export function transpose<T>(matrix: T[][]): any[][] {
* Normalize and fill a 2D array to ensure all rows have the same length. * Normalize and fill a 2D array to ensure all rows have the same length.
* @param {any[][]} matrix - The 2D array to normalize and fill. * @param {any[][]} matrix - The 2D array to normalize and fill.
* @param {any} fillValue - The value to fill in for missing elements. * @param {any} fillValue - The value to fill in for missing elements.
* @param {number} desiredLength - The target length of the array. if given take it as maxLength.
* @returns {any[][]} - The normalized and filled 2D array. * @returns {any[][]} - The normalized and filled 2D array.
* **/ * **/
export function normalizeAndFill<T>(matrix: T[][], fillValue: T): T[][] { export function normalizeAndFill<T>(
const maxLength = Math.max(...matrix.map((row) => row.length)); matrix: T[][],
fillValue: T,
desiredLength?: number
): T[][] {
const maxLength = !desiredLength
? Math.max(...matrix.map((row) => row.length))
: desiredLength;
return matrix.map((row) => { return matrix.map((row) => {
const filledRow = [...row]; const filledRow = [...row];
while (filledRow.length < maxLength) { while (filledRow.length < maxLength) {