From d8bb9f2952588eba3667e9f4cc8475bdb1b1d2dd Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Mon, 7 Apr 2025 20:01:17 +0100 Subject: [PATCH 1/8] ci: build docker image on push --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c062fe4..8494d1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,50 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 + build-and-push-docker: + name: Build and Push Multi-Platform Docker Image + runs-on: ubuntu-latest + needs: + - test-and-build + - e2e-test + if: github.ref == 'refs/heads/main' + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + with: + platforms: 'arm64,amd64' + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: iib0011/omni-tools + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max deploy: if: github.ref == 'refs/heads/main' needs: From ba47687546d1c7494b6f1744e5b876205b02862c Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Mon, 7 Apr 2025 20:28:11 +0100 Subject: [PATCH 2/8] ci: docker image tags --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8494d1d..6959a8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,6 @@ jobs: images: iib0011/omni-tools tags: | type=raw,value=latest - type=sha,format=short - name: Build and push Docker image uses: docker/build-push-action@v4 From 92ce1184de397b58ede5cd4f9083f52ebbe145f0 Mon Sep 17 00:00:00 2001 From: Lukas Herajt Date: Tue, 8 Apr 2025 13:27:30 -0400 Subject: [PATCH 3/8] time working --- package-lock.json | 7 + package.json | 1 + .../tools/time/time-between-dates/service.ts | 214 +++++++++++++----- .../time-between-dates.service.test.ts | 90 +++++++- 4 files changed, 241 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2074f9..5dc1d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/omggif": "^1.0.5", "browser-image-compression": "^2.0.2", "color": "^4.2.3", + "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", "lint-staged": "^15.4.3", @@ -4673,6 +4674,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index 68dfa2d..7477f0d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/omggif": "^1.0.5", "browser-image-compression": "^2.0.2", "color": "^4.2.3", + "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", "lint-staged": "^15.4.3", diff --git a/src/pages/tools/time/time-between-dates/service.ts b/src/pages/tools/time/time-between-dates/service.ts index 785f392..70b247b 100644 --- a/src/pages/tools/time/time-between-dates/service.ts +++ b/src/pages/tools/time/time-between-dates/service.ts @@ -1,3 +1,12 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import duration from 'dayjs/plugin/duration'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(duration); + export const unitHierarchy = [ 'years', 'months', @@ -11,70 +20,153 @@ export const unitHierarchy = [ export type TimeUnit = (typeof unitHierarchy)[number]; export type TimeDifference = Record; +// Mapping common abbreviations to IANA time zone names +export const tzMap: { [abbr: string]: string } = { + EST: 'America/New_York', + EDT: 'America/New_York', + CST: 'America/Chicago', + CDT: 'America/Chicago', + MST: 'America/Denver', + MDT: 'America/Denver', + PST: 'America/Los_Angeles', + PDT: 'America/Los_Angeles', + GMT: 'Etc/GMT', + UTC: 'Etc/UTC' + // add more mappings as needed +}; + +// Parse a date string with a time zone abbreviation, +// e.g. "02/02/2024 14:55 EST" +export const parseWithTZ = (dateTimeStr: string): dayjs.Dayjs => { + const parts = dateTimeStr.trim().split(' '); + const tzAbbr = parts.pop()!; // extract the timezone part (e.g., EST) + const dateTimePart = parts.join(' '); + const tzName = tzMap[tzAbbr]; + if (!tzName) { + throw new Error(`Timezone abbreviation ${tzAbbr} not supported`); + } + // Parse using the format "MM/DD/YYYY HH:mm" in the given time zone + return dayjs.tz(dateTimePart, 'MM/DD/YYYY HH:mm', tzName); +}; + export const calculateTimeBetweenDates = ( startDate: Date, endDate: Date ): TimeDifference => { - if (endDate < startDate) { - const temp = startDate; - startDate = endDate; - endDate = temp; + let start = dayjs(startDate); + let end = dayjs(endDate); + + // Swap dates if start is after end + if (end.isBefore(start)) { + [start, end] = [end, start]; } - const milliseconds = endDate.getTime() - startDate.getTime(); - const seconds = Math.floor(milliseconds / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); + // Calculate each unit incrementally so that the remainder is applied for subsequent units. + const years = end.diff(start, 'year'); + const startPlusYears = start.add(years, 'year'); - // Approximate months and years - const startYear = startDate.getFullYear(); - const startMonth = startDate.getMonth(); - const endYear = endDate.getFullYear(); - const endMonth = endDate.getMonth(); + const months = end.diff(startPlusYears, 'month'); + const startPlusMonths = startPlusYears.add(months, 'month'); - const months = (endYear - startYear) * 12 + (endMonth - startMonth); - const years = Math.floor(months / 12); + const days = end.diff(startPlusMonths, 'day'); + const startPlusDays = startPlusMonths.add(days, 'day'); + + const hours = end.diff(startPlusDays, 'hour'); + const startPlusHours = startPlusDays.add(hours, 'hour'); + + const minutes = end.diff(startPlusHours, 'minute'); + const startPlusMinutes = startPlusHours.add(minutes, 'minute'); + + const seconds = end.diff(startPlusMinutes, 'second'); + const startPlusSeconds = startPlusMinutes.add(seconds, 'second'); + + const milliseconds = end.diff(startPlusSeconds, 'millisecond'); return { - milliseconds, - seconds, - minutes, - hours, - days, + years, months, - years + days, + hours, + minutes, + seconds, + milliseconds }; }; +// Calculate duration between two date strings with timezone abbreviations +export const getDuration = ( + startStr: string, + endStr: string +): TimeDifference => { + const start = parseWithTZ(startStr); + const end = parseWithTZ(endStr); + + if (end.isBefore(start)) { + throw new Error('End date must be after start date'); + } + + return calculateTimeBetweenDates(start.toDate(), end.toDate()); +}; + export const formatTimeDifference = ( difference: TimeDifference, - includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1) + includeUnits: TimeUnit[] = unitHierarchy.slice(0, -2) ): string => { - const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [ - { key: 'years', value: difference.years }, - { key: 'months', value: difference.months, divisor: 12 }, - { key: 'days', value: difference.days, divisor: 30 }, - { key: 'hours', value: difference.hours, divisor: 24 }, - { key: 'minutes', value: difference.minutes, divisor: 60 }, - { key: 'seconds', value: difference.seconds, divisor: 60 } + // First normalize the values (convert 24 hours to 1 day, etc.) + const normalized = { ...difference }; + + // Convert milliseconds to seconds + if (normalized.milliseconds >= 1000) { + const additionalSeconds = Math.floor(normalized.milliseconds / 1000); + normalized.seconds += additionalSeconds; + normalized.milliseconds %= 1000; + } + + // Convert seconds to minutes + if (normalized.seconds >= 60) { + const additionalMinutes = Math.floor(normalized.seconds / 60); + normalized.minutes += additionalMinutes; + normalized.seconds %= 60; + } + + // Convert minutes to hours + if (normalized.minutes >= 60) { + const additionalHours = Math.floor(normalized.minutes / 60); + normalized.hours += additionalHours; + normalized.minutes %= 60; + } + + // Convert hours to days if 24 or more + if (normalized.hours >= 24) { + const additionalDays = Math.floor(normalized.hours / 24); + normalized.days += additionalDays; + normalized.hours %= 24; + } + + const timeUnits: { key: TimeUnit; value: number; label: string }[] = [ + { key: 'years', value: normalized.years, label: 'year' }, + { key: 'months', value: normalized.months, label: 'month' }, + { key: 'days', value: normalized.days, label: 'day' }, + { key: 'hours', value: normalized.hours, label: 'hour' }, + { key: 'minutes', value: normalized.minutes, label: 'minute' }, + { key: 'seconds', value: normalized.seconds, label: 'second' }, + { + key: 'milliseconds', + value: normalized.milliseconds, + label: 'millisecond' + } ]; const parts = timeUnits .filter(({ key }) => includeUnits.includes(key)) - .map(({ key, value, divisor }) => { - const remaining = divisor ? value % divisor : value; - return remaining > 0 ? `${remaining} ${key}` : ''; + .map(({ value, label }) => { + if (value === 0) return ''; + return `${value} ${label}${value === 1 ? '' : 's'}`; }) .filter(Boolean); if (parts.length === 0) { - if (includeUnits.includes('milliseconds')) { - return `${difference.milliseconds} millisecond${ - difference.milliseconds === 1 ? '' : 's' - }`; - } - return '0 seconds'; + return '0 minutes'; } return parts.join(', '); @@ -85,45 +177,49 @@ export const getTimeWithTimezone = ( timeString: string, timezone: string ): Date => { - // Combine date and time - const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing - const utcDate = new Date(dateTimeString); - - if (isNaN(utcDate.getTime())) { - throw new Error('Invalid date or time format'); - } - // If timezone is "local", return the local date if (timezone === 'local') { - return utcDate; + const dateTimeString = `${dateString}T${timeString}`; + return dayjs(dateTimeString).toDate(); } - // Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4") + // Check if the timezone is a known abbreviation + if (tzMap[timezone]) { + const dateTimeString = `${dateString} ${timeString}`; + return dayjs + .tz(dateTimeString, 'YYYY-MM-DD HH:mm', tzMap[timezone]) + .toDate(); + } + + // Handle GMT+/- format const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/); if (!match) { throw new Error('Invalid timezone format'); } + const dateTimeString = `${dateString}T${timeString}Z`; + const utcDate = dayjs.utc(dateTimeString); + + if (!utcDate.isValid()) { + throw new Error('Invalid date or time format'); + } + const offsetHours = match[1] ? parseInt(match[1], 10) : 0; const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0; - const totalOffsetMinutes = offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes); - // Adjust the UTC date by the timezone offset - return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000); + return utcDate.subtract(totalOffsetMinutes, 'minute').toDate(); }; -// Helper function to format time based on largest unit export const formatTimeWithLargestUnit = ( difference: TimeDifference, largestUnit: TimeUnit ): string => { const largestUnitIndex = unitHierarchy.indexOf(largestUnit); - const unitsToInclude = unitHierarchy.slice(largestUnitIndex); - - // Preserve only whole values, do not apply fractional conversions - const adjustedDifference: TimeDifference = { ...difference }; - - return formatTimeDifference(adjustedDifference, unitsToInclude); + const unitsToInclude = unitHierarchy.slice( + largestUnitIndex, + unitHierarchy.length // Include milliseconds if it's the largest unit requested + ); + return formatTimeDifference(difference, unitsToInclude); }; diff --git a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts index 5a8db7f..8c1dbc1 100644 --- a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts +++ b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from 'vitest'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import duration from 'dayjs/plugin/duration'; import { calculateTimeBetweenDates, formatTimeDifference, @@ -6,31 +10,78 @@ import { getTimeWithTimezone } from './service'; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(duration); + // Utility function to create a date const createDate = ( year: number, month: number, day: number, hours = 0, - minutes = 0, - seconds = 0 -) => new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds)); + minutes = 0 +) => dayjs.utc(Date.UTC(year, month - 1, day, hours, minutes)).toDate(); + describe('calculateTimeBetweenDates', () => { - it('should calculate the correct time difference', () => { + it('should calculate exactly 1 year difference', () => { const startDate = createDate(2023, 1, 1); const endDate = createDate(2024, 1, 1); const result = calculateTimeBetweenDates(startDate, endDate); expect(result.years).toBe(1); - expect(result.months).toBe(12); - expect(result.days).toBeGreaterThanOrEqual(365); + expect(result.months).toBe(0); + expect(result.days).toBe(0); + expect(result.hours).toBe(0); + expect(result.minutes).toBe(0); + }); + + it('should calculate 1 year and 1 day difference', () => { + const startDate = createDate(2023, 1, 1); + const endDate = createDate(2024, 1, 2); + const result = calculateTimeBetweenDates(startDate, endDate); + + expect(result.years).toBe(1); + expect(result.months).toBe(0); + expect(result.days).toBe(1); + expect(result.hours).toBe(0); + expect(result.minutes).toBe(0); + }); + + it('should handle leap year correctly', () => { + const startDate = createDate(2024, 2, 28); // February 28th in leap year + const endDate = createDate(2024, 3, 1); // March 1st + const result = calculateTimeBetweenDates(startDate, endDate); + + expect(result.days).toBe(2); + expect(result.months).toBe(0); + expect(result.years).toBe(0); + expect(result.hours).toBe(0); + expect(result.minutes).toBe(0); }); it('should swap dates if startDate is after endDate', () => { const startDate = createDate(2024, 1, 1); const endDate = createDate(2023, 1, 1); const result = calculateTimeBetweenDates(startDate, endDate); + expect(result.years).toBe(1); + expect(result.months).toBe(0); + expect(result.days).toBe(0); + expect(result.hours).toBe(0); + expect(result.minutes).toBe(0); + }); + + it('should handle same day different hours', () => { + const startDate = createDate(2024, 1, 1, 10); + const endDate = createDate(2024, 1, 1, 15); + const result = calculateTimeBetweenDates(startDate, endDate); + + expect(result.years).toBe(0); + expect(result.months).toBe(0); + expect(result.days).toBe(0); + expect(result.hours).toBe(5); + expect(result.minutes).toBe(0); }); }); @@ -46,11 +97,26 @@ describe('formatTimeDifference', () => { milliseconds: 0 }; expect(formatTimeDifference(difference)).toBe( - '1 years, 2 months, 10 days, 5 hours, 30 minutes' + '1 year, 2 months, 10 days, 5 hours, 30 minutes' ); }); - it('should return 0 seconds if all values are zero', () => { + it('should handle singular units correctly', () => { + const difference = { + years: 1, + months: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 0, + milliseconds: 0 + }; + expect(formatTimeDifference(difference)).toBe( + '1 year, 1 month, 1 day, 1 hour, 1 minute' + ); + }); + + it('should return 0 minutes if all values are zero', () => { expect( formatTimeDifference({ years: 0, @@ -61,7 +127,7 @@ describe('formatTimeDifference', () => { seconds: 0, milliseconds: 0 }) - ).toBe('0 seconds'); + ).toBe('0 minutes'); }); }); @@ -85,12 +151,12 @@ describe('formatTimeWithLargestUnit', () => { months: 1, days: 15, hours: 12, - minutes: 0, + minutes: 30, seconds: 0, milliseconds: 0 }; - expect(formatTimeWithLargestUnit(difference, 'days')).toContain( - '15 days, 12 hours' + expect(formatTimeWithLargestUnit(difference, 'days')).toBe( + '15 days, 12 hours, 30 minutes' ); }); }); From c8150ad87362a44b2138b70ec135e44e717ccdb7 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Thu, 10 Apr 2025 15:26:25 +0200 Subject: [PATCH 4/8] fix : correct short description --- src/pages/tools/csv/csv-rows-to-columns/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/csv/csv-rows-to-columns/meta.ts b/src/pages/tools/csv/csv-rows-to-columns/meta.ts index e4d8c3d..49dd3bc 100644 --- a/src/pages/tools/csv/csv-rows-to-columns/meta.ts +++ b/src/pages/tools/csv/csv-rows-to-columns/meta.ts @@ -9,7 +9,7 @@ export const tool = defineTool('csv', { 'This tool converts rows of a CSV (Comma Separated Values) file into columns. It extracts the horizontal lines from the input CSV one by one, rotates them 90 degrees, and outputs them as vertical columns one after another, separated by commas.', longDescription: 'This tool converts rows of a CSV (Comma Separated Values) file into columns. For example, if the input CSV data has 6 rows, then the output will have 6 columns and the elements of the rows will be arranged from the top to bottom. In a well-formed CSV, the number of values in each row is the same. However, in cases when rows are missing fields, the program can fix them and you can choose from the available options: fill missing data with empty elements or replace missing data with custom elements, such as "missing", "?", or "x". During the conversion process, the tool also cleans the CSV file from unnecessary information, such as empty lines (these are lines without visible information) and comments. To help the tool correctly identify comments, in the options, you can specify the symbol at the beginning of a line that starts a comment. This symbol is typically a hash "#" or double slash "//". Csv-abulous!.', - shortDescription: 'Convert CSV data to JSON format', + shortDescription: 'Convert CSV rows to columns', keywords: ['csv', 'rows', 'columns', 'transpose'], component: lazy(() => import('./index')) }); From caae769a69820f4aa5ddf0667a2c6599f1ce8f0e Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Thu, 10 Apr 2025 15:53:59 +0200 Subject: [PATCH 5/8] fix: icon change (allow right icon for transpose feat) --- src/pages/tools/csv/csv-rows-to-columns/meta.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/tools/csv/csv-rows-to-columns/meta.ts b/src/pages/tools/csv/csv-rows-to-columns/meta.ts index 49dd3bc..4cecc02 100644 --- a/src/pages/tools/csv/csv-rows-to-columns/meta.ts +++ b/src/pages/tools/csv/csv-rows-to-columns/meta.ts @@ -4,7 +4,7 @@ import { lazy } from 'react'; export const tool = defineTool('csv', { name: 'Convert CSV Rows to Columns', path: 'csv-rows-to-columns', - icon: 'carbon:transpose', + icon: 'fluent:text-arrow-down-right-column-24-filled', description: 'This tool converts rows of a CSV (Comma Separated Values) file into columns. It extracts the horizontal lines from the input CSV one by one, rotates them 90 degrees, and outputs them as vertical columns one after another, separated by commas.', longDescription: From c8a320cf18eee95ef0aa2ea141da40d166d70bdc Mon Sep 17 00:00:00 2001 From: "Ibrahima G. Coulibaly" Date: Thu, 10 Apr 2025 16:56:51 +0100 Subject: [PATCH 6/8] chore: ensure timezones uniqueness in time-between-dates --- .../tools/time/time-between-dates/index.tsx | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/pages/tools/time/time-between-dates/index.tsx b/src/pages/tools/time/time-between-dates/index.tsx index a02ee70..94491b9 100644 --- a/src/pages/tools/time/time-between-dates/index.tsx +++ b/src/pages/tools/time/time-between-dates/index.tsx @@ -50,26 +50,31 @@ const validationSchema = Yup.object({ const timezoneOptions = [ { value: 'local', label: 'Local Time' }, - ...Intl.supportedValuesOf('timeZone') - .map((tz) => { - const formatter = new Intl.DateTimeFormat('en', { - timeZone: tz, - timeZoneName: 'shortOffset' - }); + ...Array.from( + new Map( + Intl.supportedValuesOf('timeZone').map((tz) => { + const formatter = new Intl.DateTimeFormat('en', { + timeZone: tz, + timeZoneName: 'shortOffset' + }); - const offset = - formatter - .formatToParts(new Date()) - .find((part) => part.type === 'timeZoneName')?.value || ''; + const offset = + formatter + .formatToParts(new Date()) + .find((part) => part.type === 'timeZoneName')?.value || ''; - return { - value: offset.replace('UTC', 'GMT'), - label: `${offset.replace('UTC', 'GMT')} (${tz})` - }; - }) - .sort((a, b) => - a.value.localeCompare(b.value, undefined, { numeric: true }) - ) + const value = offset.replace('UTC', 'GMT'); + + return [ + value, // key for Map to ensure uniqueness + { + value, + label: `${value} (${tz})` + } + ]; + }) + ).values() + ).sort((a, b) => a.value.localeCompare(b.value, undefined, { numeric: true })) ]; const exampleCards: CardExampleType[] = [ From c4d78a6c0cdd9d09b73b663219e979185b6e0b63 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Thu, 10 Apr 2025 18:34:04 +0200 Subject: [PATCH 7/8] utils: array util functions (any types) --- src/utils/array.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/utils/array.ts diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..3ab7c50 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,26 @@ +/** + * Transpose a 2D array (matrix). + * @param {any[][]} matrix - The 2D array to transpose. + * @returns {any[][]} - The transposed 2D array. + **/ + +export function transpose(matrix: T[][]): any[][] { + return matrix[0].map((_, colIndex) => matrix.map((row) => row[colIndex])); +} + +/** + * 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} fillValue - The value to fill in for missing elements. + * @returns {any[][]} - The normalized and filled 2D array. + * **/ +export function normalizeAndFill(matrix: T[][], fillValue: T): T[][] { + const maxLength = Math.max(...matrix.map((row) => row.length)); + return matrix.map((row) => { + const filledRow = [...row]; + while (filledRow.length < maxLength) { + filledRow.push(fillValue); + } + return filledRow; + }); +} From e48f1757e2ce51137d4e9f209b4037d0a7c0a354 Mon Sep 17 00:00:00 2001 From: Chesterkxng Date: Thu, 10 Apr 2025 18:39:09 +0200 Subject: [PATCH 8/8] feat: transpose-CSV --- src/pages/tools/csv/index.ts | 4 +- src/pages/tools/csv/transpose-csv/index.tsx | 178 ++++++++++++++++++ src/pages/tools/csv/transpose-csv/meta.ts | 15 ++ src/pages/tools/csv/transpose-csv/service.ts | 27 +++ .../transpose-csv.service.test.ts | 89 +++++++++ src/pages/tools/csv/transpose-csv/types.ts | 7 + 6 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/pages/tools/csv/transpose-csv/index.tsx create mode 100644 src/pages/tools/csv/transpose-csv/meta.ts create mode 100644 src/pages/tools/csv/transpose-csv/service.ts create mode 100644 src/pages/tools/csv/transpose-csv/transpose-csv.service.test.ts create mode 100644 src/pages/tools/csv/transpose-csv/types.ts diff --git a/src/pages/tools/csv/index.ts b/src/pages/tools/csv/index.ts index 78ae56b..3e0a9da 100644 --- a/src/pages/tools/csv/index.ts +++ b/src/pages/tools/csv/index.ts @@ -1,3 +1,4 @@ +import { tool as transposeCsv } from './transpose-csv/meta'; import { tool as findIncompleteCsvRecords } from './find-incomplete-csv-records/meta'; import { tool as ChangeCsvDelimiter } from './change-csv-separator/meta'; import { tool as csvToYaml } from './csv-to-yaml/meta'; @@ -15,5 +16,6 @@ export const csvTools = [ swapCsvColumns, csvToYaml, ChangeCsvDelimiter, - findIncompleteCsvRecords + findIncompleteCsvRecords, + transposeCsv ]; diff --git a/src/pages/tools/csv/transpose-csv/index.tsx b/src/pages/tools/csv/transpose-csv/index.tsx new file mode 100644 index 0000000..629cceb --- /dev/null +++ b/src/pages/tools/csv/transpose-csv/index.tsx @@ -0,0 +1,178 @@ +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 { transposeCSV } from './service'; +import { InitialValuesType } from './types'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import SelectWithDesc from '@components/options/SelectWithDesc'; + +const initialValues: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' +}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Transpose a 2x3 CSV', + description: + 'This example transposes a CSV with 2 rows and 3 columns. The tool splits the input data by the comma character, creating a 2 by 3 matrix. It then exchanges elements, turning columns into rows and vice versa. The output is a transposed CSV with flipped dimensions', + sampleText: `foo,bar,baz +val1,val2,val3`, + sampleResult: `foo,val1 +bar,val2 +baz,val3`, + sampleOptions: { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + } + }, + { + title: 'Transpose a Long CSV', + description: + 'In this example, we flip a vertical single-column CSV file containing a list of our favorite fruits and their emojis. This single column is transformed into a single-row CSV file and the rows length matches the height of the original CSV.', + sampleText: `Tasty Fruit +🍑 peaches +🍒 cherries +🥝 kiwis +🍓 strawberries +🍎 apples +🍐 pears +🥭 mangos +🍍 pineapples +🍌 bananas +🍊 tangerines +🍉 watermelons +🍇 grapes`, + sampleResult: `fTasty Fruit,🍑 peaches,🍒 cherries,🥝 kiwis,🍓 strawberries,🍎 apples,🍐 pears,🥭 mangos,🍍 pineapples,🍌 bananas,🍊 tangerines,🍉 watermelons,🍇 grapes`, + sampleOptions: { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + } + }, + { + title: 'Clean and Transpose CSV Data', + description: + 'In this example, we perform three tasks simultaneously: transpose a CSV file, remove comments and empty lines, and fix missing data. The transposition operation is the same as flipping a matrix across its diagonal and it is done automatically by the program. Additionally, the program automatically removes all empty lines as they cannot be transposed. The comments are removed by specifying the "#" symbol in the options. The program also fixes missing data using a custom bullet symbol "•", which is specified in the options.', + sampleText: `Fish Type,Color,Habitat +Goldfish,Gold,Freshwater + +#Clownfish,Orange,Coral Reefs +Tuna,Silver,Saltwater + +Shark,Grey,Saltwater +Salmon,Silver`, + sampleResult: `Fish Type,Goldfish,Tuna,Shark,Salmon +Color,Gold,Silver,Grey,Silver +Habitat,Freshwater,Saltwater,Saltwater,•`, + sampleOptions: { + separator: ',', + commentCharacter: '#', + customFill: true, + customFillValue: '•', + quoteChar: '"' + } + } +]; +export default function TransposeCsv({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (values: InitialValuesType, input: string) => { + setResult(transposeCSV(input, values)); + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'Csv input Options', + component: ( + + updateField('separator', val)} + description={ + 'Enter the character used to delimit columns in the CSV input file.' + } + /> + updateField('quoteChar', val)} + description={ + 'Enter the quote character used to quote the CSV input fields.' + } + /> + updateField('commentCharacter', val)} + description={ + 'Enter the character indicating the start of a comment line. Lines starting with this symbol will be skipped.' + } + /> + + ) + }, + { + title: 'Fixing CSV Options', + component: ( + + updateField('customFill', value)} + description={ + 'Insert empty fields or custom values where the CSV data is missing (not empty).' + } + /> + + {values.customFill && ( + updateField('customFillValue', val)} + description={ + 'Enter the character used to fill missing values in the CSV input file.' + } + /> + )} + + ) + } + ]; + return ( + + } + resultComponent={} + initialValues={initialValues} + exampleCards={exampleCards} + getGroups={getGroups} + setInput={setInput} + compute={compute} + toolInfo={{ title: `What is a ${title}?`, description: longDescription }} + /> + ); +} diff --git a/src/pages/tools/csv/transpose-csv/meta.ts b/src/pages/tools/csv/transpose-csv/meta.ts new file mode 100644 index 0000000..2f05e33 --- /dev/null +++ b/src/pages/tools/csv/transpose-csv/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('csv', { + name: 'Transpose CSV', + path: 'transpose-csv', + icon: 'carbon:transpose', + description: + 'Just upload your CSV file in the form below, and this tool will automatically transpose your CSV. In the tool options, you can specify the character that starts the comment lines in the CSV to remove them. Additionally, if the CSV is incomplete (missing values), you can replace missing values with the empty character or a custom character.', + shortDescription: 'Quickly transpose a CSV file.', + keywords: ['transpose', 'csv'], + longDescription: + 'This tool transposes Comma Separated Values (CSV). It treats the CSV as a matrix of data and flips all elements across the main diagonal. The output contains the same CSV data as the input, but now all the rows have become columns, and all the columns have become rows. After transposition, the CSV file will have opposite dimensions. For example, if the input file has 4 columns and 3 rows, the output file will have 3 columns and 4 rows. During conversion, the program also cleans the data from unnecessary lines and corrects incomplete data. Specifically, the tool automatically deletes all empty records and comments that begin with a specific character, which you can set in the option. Additionally, in cases where the CSV data is corrupted or lost, the utility completes the file with empty fields or custom fields that can be specified in the options. Csv-abulous!', + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/csv/transpose-csv/service.ts b/src/pages/tools/csv/transpose-csv/service.ts new file mode 100644 index 0000000..efd0f73 --- /dev/null +++ b/src/pages/tools/csv/transpose-csv/service.ts @@ -0,0 +1,27 @@ +import { InitialValuesType } from './types'; +import { transpose, normalizeAndFill } from '@utils/array'; +import { splitCsv } from '@utils/csv'; + +export function transposeCSV( + input: string, + options: InitialValuesType +): string { + if (!input) return ''; + + const rows = splitCsv( + input, + true, + options.commentCharacter, + true, + options.separator, + options.quoteChar + ); + + const normalizeAndFillRows = options.customFill + ? normalizeAndFill(rows, options.customFillValue) + : normalizeAndFill(rows, ''); + + return transpose(normalizeAndFillRows) + .map((row) => row.join(options.separator)) + .join('\n'); +} diff --git a/src/pages/tools/csv/transpose-csv/transpose-csv.service.test.ts b/src/pages/tools/csv/transpose-csv/transpose-csv.service.test.ts new file mode 100644 index 0000000..887879f --- /dev/null +++ b/src/pages/tools/csv/transpose-csv/transpose-csv.service.test.ts @@ -0,0 +1,89 @@ +import { expect, describe, it } from 'vitest'; +import { transposeCSV } from './service'; +import { InitialValuesType } from './types'; + +describe('transposeCsv', () => { + it('should transpose a simple CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + }; + const input = 'a,b,c\n1,2,3'; + const expectedOutput = 'a,1\nb,2\nc,3'; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); + + it('should handle an empty CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + }; + const input = ''; + const expectedOutput = ''; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); + + it('should handle a single row CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + }; + const input = 'a,b,c'; + const expectedOutput = 'a\nb\nc'; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); + + it('should handle a single column CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: false, + customFillValue: 'x', + quoteChar: '"' + }; + const input = 'a\nb\nc'; + const expectedOutput = 'a,b,c'; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); + + it('should handle uneven rows in the CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: true, + customFillValue: 'x', + quoteChar: '"' + }; + const input = 'a,b\n1,2,3'; + const expectedOutput = 'a,1\nb,2\nx,3'; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); + + it('should skip comment in the CSV', () => { + const options: InitialValuesType = { + separator: ',', + commentCharacter: '#', + customFill: true, + customFillValue: 'x', + quoteChar: '"' + }; + const input = 'a,b\n1,2\n#c,3\nd,4'; + const expectedOutput = 'a,1,d\nb,2,4'; + const result = transposeCSV(input, options); + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/src/pages/tools/csv/transpose-csv/types.ts b/src/pages/tools/csv/transpose-csv/types.ts new file mode 100644 index 0000000..6d7cd90 --- /dev/null +++ b/src/pages/tools/csv/transpose-csv/types.ts @@ -0,0 +1,7 @@ +export type InitialValuesType = { + separator: string; + commentCharacter: string; + customFill: boolean; + customFillValue: string; + quoteChar: string; +};