feat: swap-csv-columns

This commit is contained in:
Chesterkxng
2025-03-31 18:08:22 +00:00
parent dd8ed76dcf
commit cafddb7cbf
6 changed files with 566 additions and 1 deletions

View File

@@ -2,5 +2,12 @@ import { tool as csvToJson } from './csv-to-json/meta';
import { tool as csvToXml } from './csv-to-xml/meta';
import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
import { tool as csvToTsv } from './csv-to-tsv/meta';
import { tool as swapCsvColumns } from './swap-csv-columns/meta';
export const csvTools = [csvToJson, csvToXml, csvToRowsColumns, csvToTsv];
export const csvTools = [
csvToJson,
csvToXml,
csvToRowsColumns,
csvToTsv,
swapCsvColumns
];

View File

@@ -0,0 +1,299 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import ToolContent from '@components/ToolContent';
import { GetGroupsType } from '@components/options/ToolOptions';
import { ToolComponentProps } from '@tools/defineTool';
import ToolTextInput from '@components/input/ToolTextInput';
import ToolTextResult from '@components/result/ToolTextResult';
import { CardExampleType } from '@components/examples/ToolExamples';
import SelectWithDesc from '@components/options/SelectWithDesc';
import CheckboxWithDesc from '@components/options/CheckboxWithDesc';
import SimpleRadio from '@components/options/SimpleRadio';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import { csvColumnsSwap } from './service';
const initialValues = {
fromPositionStatus: true,
toPositionStatus: true,
fromPosition: '1',
toPosition: '2',
fromHeader: '',
toHeader: '',
emptyValuesFilling: true,
customFiller: '',
deleteComment: true,
commentCharacter: '#',
emptyLines: true
};
type InitialValuesType = typeof initialValues;
const exampleCards: CardExampleType<InitialValuesType>[] = [
{
title: 'Move the Key Column to the First Position',
description:
'In this example, we use our CSV column swapping tool to bring the most important information to the first column. As we are planning to go on vacation soon, in the input of the tool, we load data about national parks that include their names and locations. To decide, which is the closest park to us, we need to see the parks location first, therefore, we swap the first and second data columns so that the "location" column is at the beginning of the CSV data.',
sampleText: `park_name,location
Yellowstone,Wyoming
Yosemite,California
Grand Canyon,Arizona
Rocky Mountain,Colorado
Zion Park,Utah`,
sampleResult: `location,park_name
Wyoming,Yellowstone
California,Yosemite
Arizona,Grand Canyon
Colorado,Rocky Mountain
Utah,Zion Park`,
sampleOptions: {
fromPositionStatus: true,
toPositionStatus: true,
fromPosition: '1',
toPosition: '2',
fromHeader: '',
toHeader: '',
emptyValuesFilling: false,
customFiller: '*',
deleteComment: false,
commentCharacter: '',
emptyLines: false
}
},
{
title: 'Reorganize Columns in Vitamins CSV',
description:
'In this example, a lab intern made a mistake and created a corrupted CSV file with mixed-up columns and missing data. To fix the file, we swap the columns based on the headers "Vitamin" and "Function" so that the "Vitamin" column becomes the first in the output data. We also fill the incomplete CSV data by adding a custom asterisk "*" symbol in place of missing values.',
sampleText: `Function,Fat-Soluble,Vitamin,Sources
Supports vision,Fat-Soluble,A,Carrots
Immune function,Water-Soluble,C,Citrus fruits
Bone health,Fat-Soluble,D,Fatty fish
Antioxidant,Fat-Soluble,E,Nuts
Blood clotting,Fat-Soluble,K,Leafy greens
Energy production,Water-Soluble,B1
Energy production,Water-Soluble,B2
Energy production,Water-Soluble,B3,Meat
Protein metabolism,Water-Soluble,B6,Poultry
Nervous system,Water-Soluble,B12,Meat`,
sampleResult: `Vitamin,Fat-Soluble,Function,Sources
A,Fat-Soluble,Supports vision,Carrots
C,Water-Soluble,Immune function,Citrus fruits
D,Fat-Soluble,Bone health,Fatty fish
E,Fat-Soluble,Antioxidant,Nuts
K,Fat-Soluble,Blood clotting,Leafy greens
B1,Water-Soluble,Energy production,*
B2,Water-Soluble,Energy production,*
B3,Water-Soluble,Energy production,Meat
B6,Water-Soluble,Protein metabolism,Poultry
B12,Water-Soluble,Nervous system,Meat`,
sampleOptions: {
fromPositionStatus: false,
toPositionStatus: false,
fromPosition: '1',
toPosition: '2',
fromHeader: 'Vitamin',
toHeader: 'Function',
emptyValuesFilling: false,
customFiller: '*',
deleteComment: false,
commentCharacter: '',
emptyLines: false
}
},
{
title: 'Place Columns Side by Side for Analysis',
description:
'In this example, we change the order of columns in a CSV dataset to have the columns essential for analysis adjacent to each other. We match the "ScreenSize" column by its name and place it in the second-to-last position "-2". This groups the "ScreenSize" and "Price" columns together, allowing us to easily compare and choose the phone we want to buy. We also remove empty lines and specify that lines starting with the "#" symbol are comments and should be left as is.',
sampleText: `Brand,Model,ScreenSize,OS,Price
Apple,iPhone 15 Pro Max,6.7″,iOS,$1299
Samsung,Galaxy S23 Ultra,6.8″,Android,$1199
Google,Pixel 7 Pro,6.4″,Android,$899
#OnePlus,11 Pro,6.7″,Android,$949
Xiaomi,13 Ultra,6.6″,Android,$849`,
sampleResult: `Brand,Model,OS,ScreenSize,Price
Apple,iPhone 15 Pro Max,iOS,6.7″,$1299
Samsung,Galaxy S23 Ultra,Android,6.8″,$1199
Google,Pixel 7 Pro,Android,6.4″,$899
Xiaomi,13 Ultra,Android,6.6″,$849`,
sampleOptions: {
fromPositionStatus: false,
toPositionStatus: true,
fromPosition: '1',
toPosition: '4',
fromHeader: 'ScreenSize',
toHeader: 'Function',
emptyValuesFilling: true,
customFiller: 'x',
deleteComment: true,
commentCharacter: '#',
emptyLines: true
}
}
];
export default function CsvToTsv({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<string>('');
const [result, setResult] = useState<string>('');
const compute = (optionsValues: typeof initialValues, input: string) => {
setResult(
csvColumnsSwap(
input,
optionsValues.fromPositionStatus,
optionsValues.fromPosition,
optionsValues.toPositionStatus,
optionsValues.toPosition,
optionsValues.fromHeader,
optionsValues.toHeader,
optionsValues.emptyValuesFilling,
optionsValues.customFiller,
optionsValues.deleteComment,
optionsValues.commentCharacter,
optionsValues.emptyLines
)
);
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Swap-From Column',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('fromPositionStatus', true)}
title="Set Column-From position"
checked={values.fromPositionStatus}
/>
{values.fromPositionStatus && (
<TextFieldWithDesc
description={'Position of the first column you want to swap'}
value={values.fromPosition}
onOwnChange={(val) => updateField('fromPosition', val)}
type="number"
/>
)}
<SimpleRadio
onClick={() => updateField('fromPositionStatus', false)}
title="Set Column-From Header"
checked={!values.fromPositionStatus}
/>
{!values.fromPositionStatus && (
<TextFieldWithDesc
description={'Header of the first column you want to swap'}
value={values.fromHeader}
onOwnChange={(val) => updateField('fromHeader', val)}
/>
)}
</Box>
)
},
{
title: 'Swap-to Column',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('toPositionStatus', true)}
title="Set Column-To position"
checked={values.toPositionStatus}
/>
{values.toPositionStatus && (
<TextFieldWithDesc
description={'Position of the second column you want to swap'}
value={values.toPosition}
onOwnChange={(val) => updateField('toPosition', val)}
type="number"
/>
)}
<SimpleRadio
onClick={() => updateField('toPositionStatus', false)}
title="Set Column-To Header"
checked={!values.toPositionStatus}
/>
{!values.toPositionStatus && (
<TextFieldWithDesc
description={'Header of the second column you want to swap'}
value={values.toHeader}
onOwnChange={(val) => updateField('toHeader', val)}
/>
)}
</Box>
)
},
{
title: 'Incomplete Data',
component: (
<Box>
<SelectWithDesc
selected={values.emptyValuesFilling}
options={[
{ label: 'Fill With Empty Values', value: true },
{ label: 'Fill with Custom Values', value: false }
]}
onChange={(value) => updateField('emptyValuesFilling', value)}
description={
'Fill incomplete CSV data with empty symbols or a custom symbol.'
}
/>
{!values.emptyValuesFilling && (
<TextFieldWithDesc
description={
'Specify a custom symbol to fill incomplete CSV data with'
}
value={values.customFiller}
onOwnChange={(val) => updateField('customFiller', val)}
/>
)}
</Box>
)
},
{
title: 'Comments and Empty Lines',
component: (
<Box>
<CheckboxWithDesc
checked={values.deleteComment}
onChange={(value) => updateField('deleteComment', value)}
title="Delete Comments"
description="if checked, comments given by the following character will be deleted"
/>
{!values.emptyValuesFilling && (
<TextFieldWithDesc
description={
'Specify the character used to start comments in the input CSV (and if needed remove them via checkbox above)'
}
value={values.commentCharacter}
onOwnChange={(val) => updateField('commentCharacter', val)}
/>
)}
<CheckboxWithDesc
checked={values.emptyLines}
onChange={(value) => updateField('emptyLines', value)}
title="Delete Empty Lines"
description="Do not include empty lines in the output data."
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={<ToolTextInput value={input} onChange={setInput} />}
resultComponent={<ToolTextResult value={result} />}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
exampleCards={exampleCards}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('csv', {
name: 'Swap CSV Columns',
path: 'swap-csv-columns',
icon: 'eva:swap-outline',
description:
'Just upload your CSV file in the form below, specify the columns to swap, and the tool will automatically change the positions of the specified columns in the output file. In the tool options, you can specify the column positions or names that you want to swap, as well as fix incomplete data and optionally remove empty records and records that have been commented out.',
shortDescription: 'Convert CSV data to TSV format',
longDescription:
'This tool reorganizes CSV data by swapping the positions of its columns. Swapping columns can enhance the readability of a CSV file by placing frequently used data together or in the front for easier data comparison and editing. For example, you can swap the first column with the last or swap the second column with the third. To swap columns based on their positions, select the "Set Column Position" mode and enter the numbers of the "from" and "to" columns to be swapped in the first and second blocks of options. For example, if you have a CSV file with four columns "1, 2, 3, 4" and swap columns with positions "2" and "4", the output CSV will have columns in the order: "1, 4, 3, 2".As an alternative to positions, you can swap columns by specifying their headers (column names on the first row of data). If you enable this mode in the options, then you can enter the column names like "location" and "city", and the program will swap these two columns. If any of the specified columns have incomplete data (some fields are missing), you can choose to skip such data or fill the missing fields with empty values or custom values (specified in the options). Additionally, you can specify the symbol used for comments in the CSV data, such as "#" or "//". If you do not need the commented lines in the output, you can remove them by using the "Delete Comments" checkbox. You can also activate the checkbox "Delete Empty Lines" to get rid of empty lines that contain no visible information. Csv-abulous!',
keywords: ['csv', 'swap', 'columns'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,81 @@
import { splitCsv } from '@utils/csv';
function retrieveFromAndTo(
headerRow: string[],
fromPositionStatus: boolean,
toPositionStatus: boolean,
fromPosition: string | '',
toPosition: string | '',
fromHeader: string | '',
toHeader: string | ''
): number[] {
const from = fromPositionStatus
? Number(fromPosition)
: headerRow.findIndex((header) => header === fromHeader) + 1;
const to = toPositionStatus
? Number(toPosition)
: headerRow.findIndex((header) => header === toHeader) + 1;
if (from <= 0 || to <= 0)
throw new Error('Invalid column positions. Check headers or positions.');
if (from > headerRow.length || to > headerRow.length)
throw new Error(`There are only ${headerRow.length} columns`);
return [from, to];
}
function swap(lines: string[][], from: number, to: number): string[][] {
if (from <= 0 || to <= 0)
throw new Error('Columns position must be greater than zero ');
return lines.map((row) => {
const newRow = [...row]; // Clone the row to avoid mutating the original
[newRow[from - 1], newRow[to - 1]] = [newRow[to - 1], newRow[from - 1]]; // Swap values
return newRow;
});
}
export function csvColumnsSwap(
input: string,
fromPositionStatus: boolean,
fromPosition: string | '',
toPositionStatus: boolean,
toPosition: string | '',
fromHeader: string | '',
toHeader: string | '',
emptyValuesFilling: boolean,
customFiller: string | '',
deleteComment: boolean,
commentCharacter: string | '',
emptyLines: boolean
) {
if (!input) {
return '';
}
// split csv input and remove comments
const rows = splitCsv(input, deleteComment, commentCharacter, emptyLines);
const columnCount = Math.max(...rows.map((row) => row.length));
for (let i = 0; i < rows.length; i++) {
for (let j = 0; j < columnCount; j++) {
if (!rows[i][j]) {
rows[i][j] = emptyValuesFilling ? '' : customFiller;
}
}
}
const positions = retrieveFromAndTo(
rows[0],
fromPositionStatus,
toPositionStatus,
fromPosition,
toPosition,
fromHeader,
toHeader
);
const result = swap(rows, positions[0], positions[1]);
return result.join('\n');
}

View File

@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { csvColumnsSwap } from './service';
describe('csvColumnsSwap', () => {
it('should swap columns by position', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
const result = csvColumnsSwap(
input,
true, // fromPositionStatus
'1', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
true, // dataCompletion
'', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
it('should swap columns by header', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
const result = csvColumnsSwap(
input,
false, // fromPositionStatus
'', // fromPosition
false, // toPositionStatus
'', // toPosition
'A', // fromHeader
'C', // toHeader
true, // dataCompletion
'', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
it('should fill missing values with custom filler', () => {
const input = 'A,B,C\n1,2\n4';
const result = csvColumnsSwap(
input,
true, // fromPositionStatus
'1', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
false, // dataCompletion
'X', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('C,B,A\nX,2,1\nX,X,4');
});
it('should skip filling missing values', () => {
const input = 'A,B,C\n1,2\n4';
const result = csvColumnsSwap(
input,
true, // fromPositionStatus
'1', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
true, // dataCompletion
'', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('C,B,A\n,2,1\n,,4');
});
it('should throw an error for invalid column positions', () => {
const input = 'A,B,C\n1,2,3\n4,5,6';
expect(() =>
csvColumnsSwap(
input,
true, // fromPositionStatus
'0', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
true, // dataCompletion
'', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
)
).toThrow('Invalid column positions. Check headers or positions.');
});
it('should handle empty input gracefully', () => {
const input = '';
const result = csvColumnsSwap(
input,
true, // fromPositionStatus
'1', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
true, // dataCompletion
'', // customFiller
false, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('');
});
it('should remove comments if deleteComment is true', () => {
const input = '# Comment\nA,B,C\n1,2,3\n4,5,6';
const result = csvColumnsSwap(
input,
true, // fromPositionStatus
'1', // fromPosition
true, // toPositionStatus
'3', // toPosition
'', // fromHeader
'', // toHeader
true, // dataCompletion
'', // customFiller
true, // deleteComment
'#', // commentCharacter
true // emptyLines
);
expect(result).toBe('C,B,A\n3,2,1\n6,5,4');
});
});

26
src/utils/csv.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Splits a CSV string into rows, skipping any blank lines.
* @param {string} input - The CSV input string.
* @param {string} commentCharacter - The character used to denote comments.
* @returns {string[][]} - The CSV rows as a 2D array.
*/
export function splitCsv(
input: string,
deleteComment: boolean,
commentCharacter: string,
deleteEmptyLines: boolean
): string[][] {
let rows = input.split('\n').map((row) => row.split(','));
// Remove comments if deleteComment is true
if (deleteComment) {
rows = rows.filter((row) => !row[0].trim().startsWith(commentCharacter));
}
// Remove empty lines if deleteEmptyLines is true
if (deleteEmptyLines) {
rows = rows.filter((row) => row.some((cell) => cell.trim() !== ''));
}
return rows;
}