From cafddb7cbfa1b05e42e874f157b74c7be5660e11 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Mon, 31 Mar 2025 18:08:22 +0000 Subject: [PATCH 01/14] feat: swap-csv-columns --- src/pages/tools/csv/index.ts | 9 +- .../tools/csv/swap-csv-columns/index.tsx | 299 ++++++++++++++++++ src/pages/tools/csv/swap-csv-columns/meta.ts | 15 + .../tools/csv/swap-csv-columns/service.ts | 81 +++++ .../swap-csv-columns.service.test.ts | 137 ++++++++ src/utils/csv.ts | 26 ++ 6 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/csv/swap-csv-columns/index.tsx create mode 100644 src/pages/tools/csv/swap-csv-columns/meta.ts create mode 100644 src/pages/tools/csv/swap-csv-columns/service.ts create mode 100644 src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts create mode 100644 src/utils/csv.ts diff --git a/src/pages/tools/csv/index.ts b/src/pages/tools/csv/index.ts index b84513c..aebb587 100644 --- a/src/pages/tools/csv/index.ts +++ b/src/pages/tools/csv/index.ts @@ -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 +]; diff --git a/src/pages/tools/csv/swap-csv-columns/index.tsx b/src/pages/tools/csv/swap-csv-columns/index.tsx new file mode 100644 index 0000000..0190347 --- /dev/null +++ b/src/pages/tools/csv/swap-csv-columns/index.tsx @@ -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[] = [ + { + 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(''); + const [result, setResult] = useState(''); + + 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 = ({ + values, + updateField + }) => [ + { + title: 'Swap-From Column', + component: ( + + updateField('fromPositionStatus', true)} + title="Set Column-From position" + checked={values.fromPositionStatus} + /> + {values.fromPositionStatus && ( + updateField('fromPosition', val)} + type="number" + /> + )} + updateField('fromPositionStatus', false)} + title="Set Column-From Header" + checked={!values.fromPositionStatus} + /> + {!values.fromPositionStatus && ( + updateField('fromHeader', val)} + /> + )} + + ) + }, + { + title: 'Swap-to Column', + component: ( + + updateField('toPositionStatus', true)} + title="Set Column-To position" + checked={values.toPositionStatus} + /> + {values.toPositionStatus && ( + updateField('toPosition', val)} + type="number" + /> + )} + updateField('toPositionStatus', false)} + title="Set Column-To Header" + checked={!values.toPositionStatus} + /> + {!values.toPositionStatus && ( + updateField('toHeader', val)} + /> + )} + + ) + }, + { + title: 'Incomplete Data', + component: ( + + updateField('emptyValuesFilling', value)} + description={ + 'Fill incomplete CSV data with empty symbols or a custom symbol.' + } + /> + {!values.emptyValuesFilling && ( + updateField('customFiller', val)} + /> + )} + + ) + }, + { + title: 'Comments and Empty Lines', + component: ( + + updateField('deleteComment', value)} + title="Delete Comments" + description="if checked, comments given by the following character will be deleted" + /> + {!values.emptyValuesFilling && ( + updateField('commentCharacter', val)} + /> + )} + + updateField('emptyLines', value)} + title="Delete Empty Lines" + description="Do not include empty lines in the output data." + /> + + ) + } + ]; + + return ( + } + resultComponent={} + initialValues={initialValues} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + exampleCards={exampleCards} + /> + ); +} diff --git a/src/pages/tools/csv/swap-csv-columns/meta.ts b/src/pages/tools/csv/swap-csv-columns/meta.ts new file mode 100644 index 0000000..50fd3bc --- /dev/null +++ b/src/pages/tools/csv/swap-csv-columns/meta.ts @@ -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')) +}); diff --git a/src/pages/tools/csv/swap-csv-columns/service.ts b/src/pages/tools/csv/swap-csv-columns/service.ts new file mode 100644 index 0000000..9dda954 --- /dev/null +++ b/src/pages/tools/csv/swap-csv-columns/service.ts @@ -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'); +} diff --git a/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts b/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts new file mode 100644 index 0000000..303bff9 --- /dev/null +++ b/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts @@ -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'); + }); +}); diff --git a/src/utils/csv.ts b/src/utils/csv.ts new file mode 100644 index 0000000..9ecca3e --- /dev/null +++ b/src/utils/csv.ts @@ -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; +} From 477117cd8c731ca1baad96a730f87c4f1fc4ea72 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Tue, 1 Apr 2025 15:40:19 +0000 Subject: [PATCH 02/14] feat: swap-csv-columns fixed --- .../tools/csv/swap-csv-columns/index.tsx | 60 +++++++++---------- .../tools/csv/swap-csv-columns/service.ts | 54 ++++++----------- src/pages/tools/csv/swap-csv-columns/types.ts | 13 ++++ src/utils/csv.ts | 12 +++- 4 files changed, 70 insertions(+), 69 deletions(-) create mode 100644 src/pages/tools/csv/swap-csv-columns/types.ts diff --git a/src/pages/tools/csv/swap-csv-columns/index.tsx b/src/pages/tools/csv/swap-csv-columns/index.tsx index 0190347..06bb838 100644 --- a/src/pages/tools/csv/swap-csv-columns/index.tsx +++ b/src/pages/tools/csv/swap-csv-columns/index.tsx @@ -11,8 +11,10 @@ import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; import SimpleRadio from '@components/options/SimpleRadio'; import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; import { csvColumnsSwap } from './service'; +import { getCsvHeaders } from '@utils/csv'; +import { InitialValuesType } from './types'; -const initialValues = { +const initialValues: InitialValuesType = { fromPositionStatus: true, toPositionStatus: true, fromPosition: '1', @@ -25,7 +27,7 @@ const initialValues = { commentCharacter: '#', emptyLines: true }; -type InitialValuesType = typeof initialValues; + const exampleCards: CardExampleType[] = [ { title: 'Move the Key Column to the First Position', @@ -48,8 +50,8 @@ Utah,Zion Park`, toPositionStatus: true, fromPosition: '1', toPosition: '2', - fromHeader: '', - toHeader: '', + fromHeader: 'park_name', + toHeader: 'location', emptyValuesFilling: false, customFiller: '*', deleteComment: false, @@ -120,7 +122,7 @@ Xiaomi,13 Ultra,Android,6.6″,$849`, fromPosition: '1', toPosition: '4', fromHeader: 'ScreenSize', - toHeader: 'Function', + toHeader: 'OS', emptyValuesFilling: true, customFiller: 'x', deleteComment: true, @@ -137,25 +139,16 @@ export default function CsvToTsv({ const [input, setInput] = useState(''); const [result, setResult] = useState(''); - 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 compute = (optionsValues: InitialValuesType, input: string) => { + setResult(csvColumnsSwap(input, optionsValues)); }; + const headers = getCsvHeaders(input); + const headerOptions = headers.map((item) => ({ + label: `${item}`, + value: item + })); + const getGroups: GetGroupsType = ({ values, updateField @@ -175,18 +168,21 @@ export default function CsvToTsv({ value={values.fromPosition} onOwnChange={(val) => updateField('fromPosition', val)} type="number" + inputProps={{ min: 1, max: headers.length }} /> )} + updateField('fromPositionStatus', false)} title="Set Column-From Header" checked={!values.fromPositionStatus} /> {!values.fromPositionStatus && ( - updateField('fromHeader', val)} + updateField('fromHeader', value)} + description={'Header of the first column you want to swap.'} /> )} @@ -207,6 +203,7 @@ export default function CsvToTsv({ value={values.toPosition} onOwnChange={(val) => updateField('toPosition', val)} type="number" + inputProps={{ min: 1, max: headers.length }} /> )} {!values.toPositionStatus && ( - updateField('toHeader', val)} + updateField('toHeader', value)} + description={'Header of the second column you want to swap..'} /> )} @@ -261,7 +259,7 @@ export default function CsvToTsv({ title="Delete Comments" description="if checked, comments given by the following character will be deleted" /> - {!values.emptyValuesFilling && ( + {values.deleteComment && ( header === fromHeader) + 1; + const from = options.fromPositionStatus + ? Number(options.fromPosition) + : headerRow.findIndex((header) => header === options.fromHeader) + 1; - const to = toPositionStatus - ? Number(toPosition) - : headerRow.findIndex((header) => header === toHeader) + 1; + const to = options.toPositionStatus + ? Number(options.toPosition) + : headerRow.findIndex((header) => header === options.toHeader) + 1; if (from <= 0 || to <= 0) throw new Error('Invalid column positions. Check headers or positions.'); @@ -37,44 +33,28 @@ function swap(lines: string[][], from: number, to: number): string[][] { }); } -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 -) { +export function csvColumnsSwap(input: string, options: InitialValuesType) { if (!input) { return ''; } // split csv input and remove comments - const rows = splitCsv(input, deleteComment, commentCharacter, emptyLines); + const rows = splitCsv( + input, + options.deleteComment, + options.commentCharacter, + options.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; + rows[i][j] = options.emptyValuesFilling ? '' : options.customFiller; } } } - const positions = retrieveFromAndTo( - rows[0], - fromPositionStatus, - toPositionStatus, - fromPosition, - toPosition, - fromHeader, - toHeader - ); + const positions = retrieveFromAndTo(rows[0], options); const result = swap(rows, positions[0], positions[1]); return result.join('\n'); diff --git a/src/pages/tools/csv/swap-csv-columns/types.ts b/src/pages/tools/csv/swap-csv-columns/types.ts new file mode 100644 index 0000000..79a625d --- /dev/null +++ b/src/pages/tools/csv/swap-csv-columns/types.ts @@ -0,0 +1,13 @@ +export type InitialValuesType = { + fromPositionStatus: boolean; + fromPosition: string | ''; + toPositionStatus: boolean; + toPosition: string | ''; + fromHeader: string | ''; + toHeader: string | ''; + emptyValuesFilling: boolean; + customFiller: string | ''; + deleteComment: boolean; + commentCharacter: string | ''; + emptyLines: boolean; +}; diff --git a/src/utils/csv.ts b/src/utils/csv.ts index 9ecca3e..4830b1f 100644 --- a/src/utils/csv.ts +++ b/src/utils/csv.ts @@ -13,7 +13,7 @@ export function splitCsv( let rows = input.split('\n').map((row) => row.split(',')); // Remove comments if deleteComment is true - if (deleteComment) { + if (deleteComment && commentCharacter) { rows = rows.filter((row) => !row[0].trim().startsWith(commentCharacter)); } @@ -24,3 +24,13 @@ export function splitCsv( return rows; } + +/** + * get the headers from a CSV string . + * @param {string} input - The CSV input string. + * @returns {string[]} - The CSV header as a 1D array. + */ +export function getCsvHeaders(csvString: string): string[] { + const rows = csvString.split('\n').map((row) => row.split(',')); + return rows.length > 0 ? rows[0].map((header) => header.trim()) : []; +} From 863f7d05fa77fce580117417f131a291435479f2 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Tue, 1 Apr 2025 16:01:39 +0000 Subject: [PATCH 03/14] fix examples --- .../swap-csv-columns.service.test.ts | 204 +++++++++--------- 1 file changed, 104 insertions(+), 100 deletions(-) diff --git a/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts b/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts index 303bff9..308ac93 100644 --- a/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts +++ b/src/pages/tools/csv/swap-csv-columns/swap-csv-columns.service.test.ts @@ -1,137 +1,141 @@ import { describe, it, expect } from 'vitest'; +import { InitialValuesType } from './types'; 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 - ); + + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '1', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + + const result = csvColumnsSwap(input, options); 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 - ); + const options: InitialValuesType = { + fromPositionStatus: false, // fromPositionStatus + fromPosition: '', // fromPosition + toPositionStatus: false, // toPositionStatus + toPosition: '', // toPosition + fromHeader: 'A', // fromHeader + toHeader: 'C', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + const result = csvColumnsSwap(input, options); 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 - ); + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '1', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: false, // dataCompletion + customFiller: 'X', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + const result = csvColumnsSwap(input, options); 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 - ); + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '1', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + const result = csvColumnsSwap(input, options); 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.'); + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '0', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + expect(() => csvColumnsSwap(input, options)).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 - ); + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '1', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: false, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + const result = csvColumnsSwap(input, options); 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 - ); + const options: InitialValuesType = { + fromPositionStatus: true, // fromPositionStatus + fromPosition: '1', // fromPosition + toPositionStatus: true, // toPositionStatus + toPosition: '3', // toPosition + fromHeader: '', // fromHeader + toHeader: '', // toHeader + emptyValuesFilling: true, // dataCompletion + customFiller: '', // customFiller + deleteComment: true, // deleteComment + commentCharacter: '#', // commentCharacter + emptyLines: true // emptyLines + }; + const result = csvColumnsSwap(input, options); expect(result).toBe('C,B,A\n3,2,1\n6,5,4'); }); }); From 5e2cd8eeefe708ec435a49f0343060651322dace Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Wed, 2 Apr 2025 02:24:38 +0000 Subject: [PATCH 04/14] chore: tool description --- src/pages/tools/csv/swap-csv-columns/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/csv/swap-csv-columns/meta.ts b/src/pages/tools/csv/swap-csv-columns/meta.ts index 50fd3bc..3a2f378 100644 --- a/src/pages/tools/csv/swap-csv-columns/meta.ts +++ b/src/pages/tools/csv/swap-csv-columns/meta.ts @@ -7,7 +7,7 @@ export const tool = defineTool('csv', { 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', + shortDescription: 'Reorder CSV columns', 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'], From 08801116a7adb73d6a96090cfcb5be15907a5d79 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Wed, 2 Apr 2025 03:46:42 +0000 Subject: [PATCH 05/14] feat: image resize init --- src/pages/tools/image/generic/index.ts | 3 + .../tools/image/generic/resize/index.tsx | 276 ++++++++++++++++++ src/pages/tools/image/generic/resize/meta.ts | 22 ++ src/pages/tools/image/index.ts | 3 +- .../tools/image/png/compress-png/meta.ts | 2 +- src/tools/defineTool.tsx | 4 +- src/tools/index.ts | 7 + 7 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/pages/tools/image/generic/index.ts create mode 100644 src/pages/tools/image/generic/resize/index.tsx create mode 100644 src/pages/tools/image/generic/resize/meta.ts diff --git a/src/pages/tools/image/generic/index.ts b/src/pages/tools/image/generic/index.ts new file mode 100644 index 0000000..15f2930 --- /dev/null +++ b/src/pages/tools/image/generic/index.ts @@ -0,0 +1,3 @@ +import { tool as resizeImage } from './resize/meta'; + +export const imageGenericTools = [resizeImage]; diff --git a/src/pages/tools/image/generic/resize/index.tsx b/src/pages/tools/image/generic/resize/index.tsx new file mode 100644 index 0000000..33ba506 --- /dev/null +++ b/src/pages/tools/image/generic/resize/index.tsx @@ -0,0 +1,276 @@ +import { Box } from '@mui/material'; +import React, { useState } from 'react'; +import * as Yup from 'yup'; +import ToolImageInput from '@components/input/ToolImageInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import SimpleRadio from '@components/options/SimpleRadio'; +import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; + +const initialValues = { + resizeMethod: 'pixels' as 'pixels' | 'percentage', + dimensionType: 'width' as 'width' | 'height', + width: '800', + height: '600', + percentage: '50', + maintainAspectRatio: true +}; + +type InitialValuesType = typeof initialValues; + +const validationSchema = Yup.object({ + width: Yup.number().when('resizeMethod', { + is: 'pixels', + then: (schema) => + schema.min(1, 'Width must be at least 1px').required('Width is required') + }), + height: Yup.number().when('resizeMethod', { + is: 'pixels', + then: (schema) => + schema + .min(1, 'Height must be at least 1px') + .required('Height is required') + }), + percentage: Yup.number().when('resizeMethod', { + is: 'percentage', + then: (schema) => + schema + .min(1, 'Percentage must be at least 1%') + .max(1000, 'Percentage must be at most 1000%') + .required('Percentage is required') + }) +}); + +export default function ResizeImage({ title }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + + const compute = (optionsValues: InitialValuesType, input: any) => { + if (!input) return; + + const { + resizeMethod, + dimensionType, + width, + height, + percentage, + maintainAspectRatio + } = optionsValues; + + const processImage = async (file: File) => { + // Create canvas + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (ctx == null) return; + + // Load image + const img = new Image(); + img.src = URL.createObjectURL(file); + await img.decode(); + + // Calculate new dimensions + let newWidth = img.width; + let newHeight = img.height; + + if (resizeMethod === 'pixels') { + if (dimensionType === 'width') { + newWidth = parseInt(width); + if (maintainAspectRatio) { + newHeight = Math.round((newWidth / img.width) * img.height); + } else { + newHeight = parseInt(height); + } + } else { + // height + newHeight = parseInt(height); + if (maintainAspectRatio) { + newWidth = Math.round((newHeight / img.height) * img.width); + } else { + newWidth = parseInt(width); + } + } + } else { + // percentage + const scale = parseInt(percentage) / 100; + newWidth = Math.round(img.width * scale); + newHeight = Math.round(img.height * scale); + } + + // Set canvas dimensions + canvas.width = newWidth; + canvas.height = newHeight; + + // Draw resized image + ctx.drawImage(img, 0, 0, newWidth, newHeight); + + // Determine output type based on input file + let outputType = 'image/png'; + if (file.type) { + outputType = file.type; + } + + // Convert canvas to blob and create file + canvas.toBlob((blob) => { + if (blob) { + const newFile = new File([blob], file.name, { + type: outputType + }); + setResult(newFile); + } + }, outputType); + }; + + processImage(input); + }; + + const getGroups: GetGroupsType = ({ + values, + updateField + }) => [ + { + title: 'Resize Method', + component: ( + + updateField('resizeMethod', 'pixels')} + checked={values.resizeMethod === 'pixels'} + description={'Resize by specifying dimensions in pixels.'} + title={'Resize by Pixels'} + /> + updateField('resizeMethod', 'percentage')} + checked={values.resizeMethod === 'percentage'} + description={ + 'Resize by specifying a percentage of the original size.' + } + title={'Resize by Percentage'} + /> + + ) + }, + ...(values.resizeMethod === 'pixels' + ? [ + { + title: 'Dimension Type', + component: ( + + + updateField('maintainAspectRatio', value) + } + description={ + 'Maintain the original aspect ratio of the image.' + } + title={'Maintain Aspect Ratio'} + /> + {values.maintainAspectRatio && ( + + updateField('dimensionType', 'width')} + checked={values.dimensionType === 'width'} + description={ + 'Specify the width in pixels and calculate height based on aspect ratio.' + } + title={'Set Width'} + /> + updateField('dimensionType', 'height')} + checked={values.dimensionType === 'height'} + description={ + 'Specify the height in pixels and calculate width based on aspect ratio.' + } + title={'Set Height'} + /> + + )} + updateField('width', val)} + description={'Width (in pixels)'} + disabled={ + values.maintainAspectRatio && + values.dimensionType === 'height' + } + inputProps={{ + 'data-testid': 'width-input', + type: 'number', + min: 1 + }} + /> + updateField('height', val)} + description={'Height (in pixels)'} + disabled={ + values.maintainAspectRatio && + values.dimensionType === 'width' + } + inputProps={{ + 'data-testid': 'height-input', + type: 'number', + min: 1 + }} + /> + + ) + } + ] + : [ + { + title: 'Percentage', + component: ( + + updateField('percentage', val)} + description={ + 'Percentage of original size (e.g., 50 for half size, 200 for double size)' + } + inputProps={{ + 'data-testid': 'percentage-input', + type: 'number', + min: 1, + max: 1000 + }} + /> + + ) + } + ]) + ]; + + return ( + + } + resultComponent={ + + } + toolInfo={{ + title: 'Resize Image', + description: + 'This tool allows you to resize JPG, PNG, SVG, or GIF images. You can resize by specifying dimensions in pixels or by percentage, with options to maintain the original aspect ratio.' + }} + /> + ); +} diff --git a/src/pages/tools/image/generic/resize/meta.ts b/src/pages/tools/image/generic/resize/meta.ts new file mode 100644 index 0000000..8be266a --- /dev/null +++ b/src/pages/tools/image/generic/resize/meta.ts @@ -0,0 +1,22 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('image-generic', { + name: 'Resize Image', + path: 'resize', + icon: 'mdi:resize', // Iconify icon as a string + description: + 'Resize JPG, PNG, SVG or GIF images by pixels or percentage while maintaining aspect ratio or not.', + shortDescription: 'Resize images easily.', + keywords: [ + 'resize', + 'image', + 'scale', + 'jpg', + 'png', + 'svg', + 'gif', + 'dimensions' + ], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/image/index.ts b/src/pages/tools/image/index.ts index db1cb6b..8ede982 100644 --- a/src/pages/tools/image/index.ts +++ b/src/pages/tools/image/index.ts @@ -1,3 +1,4 @@ import { pngTools } from './png'; +import { imageGenericTools } from './generic'; -export const imageTools = [...pngTools]; +export const imageTools = [...imageGenericTools, ...pngTools]; diff --git a/src/pages/tools/image/png/compress-png/meta.ts b/src/pages/tools/image/png/compress-png/meta.ts index 1e84c1f..4ed8009 100644 --- a/src/pages/tools/image/png/compress-png/meta.ts +++ b/src/pages/tools/image/png/compress-png/meta.ts @@ -8,7 +8,7 @@ export const tool = defineTool('png', { icon: 'material-symbols-light:compress', description: 'This is a program that compresses PNG pictures. As soon as you paste your PNG picture in the input area, the program will compress it and show the result in the output area. In the options, you can adjust the compression level, as well as find the old and new picture file sizes.', - shortDescription: 'Quicly compress a PNG', + shortDescription: 'Quickly compress a PNG', keywords: ['compress', 'png'], component: lazy(() => import('./index')) }); diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index 5263c6f..ace53b4 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -23,8 +23,8 @@ export type ToolCategory = | 'json' | 'time' | 'csv' - | 'time' - | 'pdf'; + | 'pdf' + | 'image-generic'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index 401cbb3..f51e8e3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -95,6 +95,13 @@ const categoriesConfig: { icon: 'fluent-mdl2:date-time', value: 'Tools for working with time and date – draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.' + }, + { + type: 'image-generic', + title: 'Image', + icon: 'material-symbols-light:image-outline-rounded', + value: + 'Tools for working with pictures – compress, resize, crop, convert to JPG, rotate, remove background and much more.' } ]; // use for changelogs From 676359ed50e99bd2a5f92ffe3e51e49fe69a5865 Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Wed, 2 Apr 2025 04:05:00 +0000 Subject: [PATCH 06/14] feat: svg resize --- .idea/workspace.xml | 167 ++++++++---------- .../tools/image/generic/resize/index.tsx | 83 +-------- .../tools/image/generic/resize/service.ts | 162 +++++++++++++++++ src/pages/tools/image/generic/resize/types.ts | 8 + 4 files changed, 248 insertions(+), 172 deletions(-) create mode 100644 src/pages/tools/image/generic/resize/service.ts create mode 100644 src/pages/tools/image/generic/resize/types.ts diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 27386d7..e85cecc 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,33 +4,11 @@