From b5839e1fe61d419dd17ad99086872dee983d5c10 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:08:16 +0200 Subject: [PATCH 01/35] Add workflow --- .github/workflows/test.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0fb8323 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,34 @@ +name: Run Tests + +on: + push: + branches: [ main, gh-pages ] + pull_request: + branches: [ main, gh-pages ] + merge_group: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: '22.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage reports + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true + verbose: true From 7335cadead527b9fd32a5e58cc0697a6a99b62e9 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:27:47 +0200 Subject: [PATCH 02/35] Don't fail if upload of coverage fails --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0fb8323..960e8f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,5 +30,4 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - verbose: true + fail_ci_if_error: false From b63355e1db73ee21b61eb28c53cc4fa9609a117d Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:40:21 +0200 Subject: [PATCH 03/35] Add SheetJS library for Excel support - Add SheetJS 0.20.3 CDN script to index.html - Use full build for maximum Excel format compatibility - No functional changes, existing CSV functionality preserved - All tests continue to pass --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index 3da1e66..27ea4d6 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,7 @@ > + From 56f02a727d4b587f99013e5fad1ea9dbb85c10c5 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:40:48 +0200 Subject: [PATCH 04/35] Add Excel parsing capability to DataObject class - Add isExcelFile() method for file type detection - Add parseExcel() method that converts Excel to CSV using SheetJS - Support multiple Excel formats (xlsx, xls, xlsm, xlsb) - Store worksheet information for potential multi-sheet support - Maintain backward compatibility with existing CSV parsing - All existing tests continue to pass --- src/data_object.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/data_object.js b/src/data_object.js index f5b51ac..c73b562 100644 --- a/src/data_object.js +++ b/src/data_object.js @@ -5,6 +5,50 @@ window.DataObject = class DataObject { this.base_json = null; } + // Detect if the file content is Excel format based on file extension or content + isExcelFile(filename) { + if (!filename) return false; + const extension = filename.toLowerCase().split('.').pop(); + return ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); + } + + // Parse Excel file and convert to CSV format that existing parseCsv can handle + parseExcel(fileContent, filename, encoding, startAtRow=1, extraRow=false, delimiter=null, worksheetIndex=0) { + try { + // Read the Excel file using SheetJS + const workbook = XLSX.read(fileContent, { type: 'binary' }); + + // Get worksheet names for potential multi-sheet support + const worksheetNames = workbook.SheetNames; + + if (worksheetNames.length === 0) { + throw new Error('No worksheets found in Excel file'); + } + + // Use specified worksheet index or default to first sheet + const worksheetName = worksheetNames[worksheetIndex] || worksheetNames[0]; + const worksheet = workbook.Sheets[worksheetName]; + + if (!worksheet) { + throw new Error(`Worksheet not found: ${worksheetName}`); + } + + // Convert worksheet to CSV format + const csvContent = XLSX.utils.sheet_to_csv(worksheet); + + // Store worksheet info for potential UI use + this.worksheetNames = worksheetNames; + this.currentWorksheet = worksheetName; + + // Now parse the CSV content using existing parseCsv method + return this.parseCsv(csvContent, encoding, startAtRow, extraRow, delimiter); + + } catch (error) { + console.error('Error parsing Excel file:', error); + throw new Error(`Failed to parse Excel file: ${error.message}`); + } + } + // Parse base csv file as JSON. This will be easier to work with. // It uses http://papaparse.com/ for handling parsing parseCsv(csv, encoding, startAtRow=1, extraRow=false, delimiter=null) { From 67d2ab57fcedf597fd7ea2a62b52613d2a996fd3 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:42:05 +0200 Subject: [PATCH 05/35] Enable Excel file uploads in UI and directives - Update file input accept attribute to include Excel formats - Modify fileread and dropzone directives to handle Excel files - Add file type detection with fallback for test environment - Route Excel files through binary reading for proper parsing - Update upload area text to mention Excel support - Add error handling for file parsing failures - All tests continue to pass --- index.html | 3 +- src/app.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 27ea4d6..0316321 100644 --- a/index.html +++ b/index.html @@ -71,13 +71,14 @@

-
Drop file
+
Drop CSV or Excel file
Or choose 0) { - if ($scope.file.chosenDelimiter == "auto") { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); - } else { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + try { + // Check if this is an Excel file + if ($scope.filename && $scope.data_object.isExcelFile($scope.filename)) { + // Parse as Excel file + $scope.data_object.parseExcel( + newValue, + $scope.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, + 0 // default to first worksheet + ); + } else { + // Parse as CSV file (existing logic) + if ($scope.file.chosenDelimiter == "auto") { + $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); + } else { + $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + } + } + $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); + } catch (error) { + console.error('Error parsing file:', error); + alert('Error parsing file: ' + error.message); } - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); } }); $scope.$watch("inverted_outflow", function (newValue, oldValue) { From 1a167933082447e701836d357ed1be32f35b7519 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:43:04 +0200 Subject: [PATCH 06/35] Add multi-sheet support and Excel UI enhancements - Add worksheet selector dropdown for Excel files with multiple sheets - Add file type indicator badge showing Excel format or CSV - Add helper methods for file type detection and display - Include worksheet switching functionality with error handling - Show worksheet selector only when multiple sheets are available - Display appropriate icons for Excel and CSV files - All tests continue to pass --- index.html | 20 +++++++++++++++++++- src/app.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 0316321..2bef51e 100644 --- a/index.html +++ b/index.html @@ -151,6 +151,19 @@
+
+ + +
@@ -176,7 +189,12 @@ Toggle column format -

YNAB Data (first 10 rows)

+

YNAB Data (first 10 rows) + + + {{getFileType(filename)}} + +

diff --git a/src/app.js b/src/app.js index 7b4c04a..b18e27b 100644 --- a/src/app.js +++ b/src/app.js @@ -204,6 +204,7 @@ angular.element(document).ready(function () { extraRow: $scope.profile.extraRow || false }; $scope.data_object = new DataObject(); + $scope.filename = null; } $scope.setInitialScopeState(); @@ -265,6 +266,12 @@ angular.element(document).ready(function () { $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); } } + + // Initialize worksheet selection for Excel files + if ($scope.filename && $scope.isExcelFile($scope.filename) && $scope.data_object.worksheetNames) { + $scope.file.selectedWorksheet = 0; // Default to first worksheet + } + $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); } catch (error) { console.error('Error parsing file:', error); @@ -295,6 +302,44 @@ angular.element(document).ready(function () { $scope.invert_flows = function () { $scope.inverted_outflow = !$scope.inverted_outflow; } + + // Helper methods for file type detection and display + $scope.isExcelFile = function(filename) { + if (!filename) return false; + var extension = filename.toLowerCase().split('.').pop(); + return ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); + }; + + $scope.getFileType = function(filename) { + if (!filename) return ''; + var extension = filename.toLowerCase().split('.').pop(); + if (['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension)) { + return extension.toUpperCase(); + } + return 'CSV'; + }; + + // Handle worksheet selection for Excel files + $scope.worksheetChosen = function(worksheetIndex) { + if ($scope.filename && $scope.data.source && $scope.isExcelFile($scope.filename)) { + try { + // Re-parse the Excel file with the selected worksheet + $scope.data_object.parseExcel( + $scope.data.source, + $scope.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, + worksheetIndex + ); + $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); + } catch (error) { + console.error('Error switching worksheet:', error); + alert('Error switching worksheet: ' + error.message); + } + } + }; $scope.downloadFile = function () { var a; var date = new Date(); From a79809eca9aa6e9de4f177a245eadbb8195dcd54 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 09:44:36 +0200 Subject: [PATCH 07/35] Add comprehensive Excel testing and update documentation - Add extensive Excel parsing tests with mocked SheetJS functionality - Test file type detection, single/multi-sheet parsing, error handling - Test worksheet selection and parameter passing - Fix worksheet index validation logic for better error messages - Update README to document Excel file support (.xlsx, .xls, .xlsm, .xlsb) - Document multi-sheet functionality in usage instructions - All 59 tests passing including 8 new Excel-specific tests --- README.md | 14 ++-- src/data_object.js | 10 ++- tests/data_object.test.js | 145 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4cbb81b..73794e1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ynab-csv -Tool for making your CSV files ready to import into YNAB. +Tool for making your CSV and Excel files ready to import into YNAB. http://aniav.github.io/ynab-csv/ @@ -9,9 +9,15 @@ http://aniav.github.io/ynab-csv/ ## How to Use 1. Visit the link above. -2. Drop or select the the csv file you want to make ready for YNAB. -3. For each column in the YNAB data file, choose which column you want to pull from your source data file. -4. Save the new YNAB file and you are ready to import that right into YNAB! +2. Drop or select the CSV or Excel file you want to make ready for YNAB. +3. For Excel files with multiple worksheets, select the desired worksheet from the dropdown. +4. For each column in the YNAB data file, choose which column you want to pull from your source data file. +5. Save the new YNAB file and you are ready to import that right into YNAB! + +## Supported File Formats + +- **CSV files** (.csv) - All standard CSV formats with configurable delimiters and encodings +- **Excel files** (.xlsx, .xls, .xlsm, .xlsb) - Full support for Excel spreadsheets including multi-sheet workbooks ## Multiple Profiles diff --git a/src/data_object.js b/src/data_object.js index c73b562..492db57 100644 --- a/src/data_object.js +++ b/src/data_object.js @@ -26,7 +26,15 @@ window.DataObject = class DataObject { } // Use specified worksheet index or default to first sheet - const worksheetName = worksheetNames[worksheetIndex] || worksheetNames[0]; + let worksheetName; + if (worksheetIndex >= 0 && worksheetIndex < worksheetNames.length) { + worksheetName = worksheetNames[worksheetIndex]; + } else if (worksheetIndex === 0 || worksheetIndex === undefined) { + worksheetName = worksheetNames[0]; + } else { + throw new Error(`Worksheet index ${worksheetIndex} is out of range. Available sheets: ${worksheetNames.length}`); + } + const worksheet = workbook.Sheets[worksheetName]; if (!worksheet) { diff --git a/tests/data_object.test.js b/tests/data_object.test.js index 05419b8..ca51de2 100644 --- a/tests/data_object.test.js +++ b/tests/data_object.test.js @@ -442,4 +442,149 @@ describe('DataObject', () => { expect(lines[2]).toContain('Purchase 2'); }); }); + + describe('Excel File Support', () => { + beforeEach(() => { + // Mock XLSX for Excel tests + global.XLSX = { + read: jest.fn(), + utils: { + sheet_to_csv: jest.fn() + } + }; + }); + + afterEach(() => { + delete global.XLSX; + }); + + describe('isExcelFile()', () => { + test('should detect Excel files by extension', () => { + expect(dataObject.isExcelFile('test.xlsx')).toBe(true); + expect(dataObject.isExcelFile('test.xls')).toBe(true); + expect(dataObject.isExcelFile('test.xlsm')).toBe(true); + expect(dataObject.isExcelFile('test.xlsb')).toBe(true); + expect(dataObject.isExcelFile('TEST.XLSX')).toBe(true); // case insensitive + }); + + test('should not detect non-Excel files', () => { + expect(dataObject.isExcelFile('test.csv')).toBe(false); + expect(dataObject.isExcelFile('test.txt')).toBe(false); + expect(dataObject.isExcelFile('test.pdf')).toBe(false); + expect(dataObject.isExcelFile('')).toBe(false); + expect(dataObject.isExcelFile(null)).toBe(false); + expect(dataObject.isExcelFile(undefined)).toBe(false); + }); + }); + + describe('parseExcel()', () => { + test('should parse Excel file with single worksheet', () => { + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Date,Description,Amount\n2024-01-01,Purchase,-50.00'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + + // Mock parseCsv to return expected data + const mockParsedData = { + data: [{ Date: '2024-01-01', Description: 'Purchase', Amount: '-50.00' }], + meta: { fields: ['Date', 'Description', 'Amount'] } + }; + dataObject.parseCsv = jest.fn().mockReturnValue(mockParsedData); + + const result = dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); + + expect(global.XLSX.read).toHaveBeenCalledWith('binary_data', { type: 'binary' }); + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Sheet1']); + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + expect(result).toEqual(mockParsedData); + expect(dataObject.worksheetNames).toEqual(['Sheet1']); + expect(dataObject.currentWorksheet).toBe('Sheet1'); + }); + + test('should parse Excel file with multiple worksheets', () => { + const mockWorkbook = { + SheetNames: ['Sheet1', 'Sheet2', 'Data'], + Sheets: { + 'Sheet1': { /* worksheet 1 data */ }, + 'Sheet2': { /* worksheet 2 data */ }, + 'Data': { /* data worksheet */ } + } + }; + const mockCsvContent = 'Name,Value\nTest,123'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test selecting a specific worksheet (index 2 = 'Data') + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 2); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Data']); + expect(dataObject.worksheetNames).toEqual(['Sheet1', 'Sheet2', 'Data']); + expect(dataObject.currentWorksheet).toBe('Data'); + }); + + test('should handle Excel parsing errors', () => { + global.XLSX.read.mockImplementation(() => { + throw new Error('Invalid Excel file'); + }); + + expect(() => { + dataObject.parseExcel('invalid_data', 'test.xlsx', 'UTF-8'); + }).toThrow('Failed to parse Excel file: Invalid Excel file'); + }); + + test('should handle empty workbook', () => { + const mockWorkbook = { + SheetNames: [], + Sheets: {} + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); + }).toThrow('Failed to parse Excel file: No worksheets found in Excel file'); + }); + + test('should handle missing worksheet', () => { + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 5); // invalid index + }).toThrow('Failed to parse Excel file: Worksheet index 5 is out of range. Available sheets: 1'); + }); + + test('should pass through all parseCsv parameters', () => { + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Date,Amount\n2024-01-01,-50.00'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + dataObject.parseExcel('binary_data', 'test.xlsx', 'ISO-8859-1', 3, true, ',', 0); + + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'ISO-8859-1', 3, true, ','); + }); + }); + }); }); \ No newline at end of file From 0a8c03e18a6ea8fac33ee1cbbda8abb720826b0e Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 11:16:41 +0200 Subject: [PATCH 08/35] Fix Excel file parsing with format-specific handling and validation - Use ArrayBuffer reading for XLS/XLSB files (OLE2 format) - Use binary string reading for XLSX/XLSM files (ZIP format) - Add comprehensive data validation in parseExcel method - Add binary garbage detection to catch corrupted files - Improve error handling with specific format guidance - Update file reading directives to handle formats appropriately - All tests continue to pass --- src/app.js | 28 +++++++++++++++++++++------ src/data_object.js | 47 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/app.js b/src/app.js index b18e27b..051415d 100644 --- a/src/app.js +++ b/src/app.js @@ -66,20 +66,28 @@ angular.element(document).ready(function () { }); }; - // Check if it's an Excel file - if so, read as binary + // Check if it's an Excel file - use appropriate reading method based on format // Fallback for test environment where DataObject might not be available var isExcel = false; + var extension = file.name.toLowerCase().split('.').pop(); + if (window.DataObject) { var dataObject = new window.DataObject(); isExcel = dataObject.isExcelFile(file.name); } else { // Simple fallback for testing - var extension = file.name.toLowerCase().split('.').pop(); isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); } if (isExcel) { - reader.readAsBinaryString(file); + // Use different reading methods for different Excel formats + if (['xls', 'xlsb'].includes(extension)) { + // XLS and XLSB files use OLE2 format, read as ArrayBuffer + reader.readAsArrayBuffer(file); + } else { + // XLSX and XLSM files are ZIP-based, read as binary string + reader.readAsBinaryString(file); + } } else { reader.readAsText(file, attributes.encoding); } @@ -135,20 +143,28 @@ angular.element(document).ready(function () { }); }; - // Check if it's an Excel file - if so, read as binary + // Check if it's an Excel file - use appropriate reading method based on format // Fallback for test environment where DataObject might not be available var isExcel = false; + var extension = file.name.toLowerCase().split('.').pop(); + if (window.DataObject) { var dataObject = new window.DataObject(); isExcel = dataObject.isExcelFile(file.name); } else { // Simple fallback for testing - var extension = file.name.toLowerCase().split('.').pop(); isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); } if (isExcel) { - reader.readAsBinaryString(file); + // Use different reading methods for different Excel formats + if (['xls', 'xlsb'].includes(extension)) { + // XLS and XLSB files use OLE2 format, read as ArrayBuffer + reader.readAsArrayBuffer(file); + } else { + // XLSX and XLSM files are ZIP-based, read as binary string + reader.readAsBinaryString(file); + } } else { reader.readAsText(file, attributes.encoding); } diff --git a/src/data_object.js b/src/data_object.js index 492db57..7abc312 100644 --- a/src/data_object.js +++ b/src/data_object.js @@ -15,13 +15,34 @@ window.DataObject = class DataObject { // Parse Excel file and convert to CSV format that existing parseCsv can handle parseExcel(fileContent, filename, encoding, startAtRow=1, extraRow=false, delimiter=null, worksheetIndex=0) { try { - // Read the Excel file using SheetJS - const workbook = XLSX.read(fileContent, { type: 'binary' }); + // Determine the appropriate data type for SheetJS based on file format + let dataType = 'binary'; + const extension = filename.toLowerCase().split('.').pop(); + + if (['xls', 'xlsb'].includes(extension)) { + // XLS and XLSB files use OLE2 format and should be read as array buffer + dataType = 'array'; + // Convert ArrayBuffer to Uint8Array if needed + if (fileContent instanceof ArrayBuffer) { + fileContent = new Uint8Array(fileContent); + } + } else { + // XLSX and XLSM files are ZIP-based and can be read as binary string + dataType = 'binary'; + } + + // Read the Excel file using SheetJS with appropriate data type + const workbook = XLSX.read(fileContent, { type: dataType }); + + // Validate that we have a proper workbook + if (!workbook || typeof workbook !== 'object') { + throw new Error('Invalid Excel file: Unable to parse workbook structure'); + } // Get worksheet names for potential multi-sheet support const worksheetNames = workbook.SheetNames; - if (worksheetNames.length === 0) { + if (!worksheetNames || worksheetNames.length === 0) { throw new Error('No worksheets found in Excel file'); } @@ -44,6 +65,16 @@ window.DataObject = class DataObject { // Convert worksheet to CSV format const csvContent = XLSX.utils.sheet_to_csv(worksheet); + // Validate that we got meaningful CSV content + if (!csvContent || csvContent.trim().length === 0) { + throw new Error('No data found in selected worksheet'); + } + + // Additional validation: check if content looks like binary garbage + if (this._containsBinaryGarbage(csvContent)) { + throw new Error('Excel file appears to be corrupted or in an unsupported format'); + } + // Store worksheet info for potential UI use this.worksheetNames = worksheetNames; this.currentWorksheet = worksheetName; @@ -57,6 +88,16 @@ window.DataObject = class DataObject { } } + // Helper method to detect if CSV content contains binary garbage + _containsBinaryGarbage(csvContent) { + // Check for excessive non-printable characters + const nonPrintableCount = (csvContent.match(/[\x00-\x08\x0E-\x1F\x7F-\xFF]/g) || []).length; + const totalLength = csvContent.length; + + // If more than 30% of content is non-printable, it's likely binary garbage + return totalLength > 0 && (nonPrintableCount / totalLength) > 0.3; + } + // Parse base csv file as JSON. This will be easier to work with. // It uses http://papaparse.com/ for handling parsing parseCsv(csv, encoding, startAtRow=1, extraRow=false, delimiter=null) { From a52770d7e01860e7f974cbbf13d4201d45caadfb Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 14:27:40 +0200 Subject: [PATCH 09/35] Fix Excel file loading when selected manually - Fixed filename not being available when Excel files are selected via file input - Implemented filename embedding approach: wrap ArrayBuffer data with filename metadata - Updated both fileread and dropzone directives to use filename embedding - Modified watcher to extract filename from data wrapper object - Excel files now properly detected and parsed instead of failing with CSV parsing Resolves issue where manually selected Excel files were processed as CSV files, causing "Failed to execute 'readAsText' on 'FileReader'" errors. --- index.html | 3 +- src/app.js | 166 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/index.html b/index.html index 2bef51e..f8723b0 100644 --- a/index.html +++ b/index.html @@ -67,7 +67,7 @@
-
+

@@ -80,6 +80,7 @@ type="file" accept=".csv,.xlsx,.xls,.xlsm,.xlsb" fileread="data.source" + filename="filename" encoding="{{file.chosenEncoding}}" delimiter="{{file.chosenDelimiter}}" /> diff --git a/src/app.js b/src/app.js index 051415d..ed63a25 100644 --- a/src/app.js +++ b/src/app.js @@ -44,27 +44,104 @@ Date.prototype.yyyymmdd = function () { }; angular.element(document).ready(function () { + console.log('[app] Angular app starting to load...'); angular.module("app", []); + console.log('[app] Angular module created'); angular.module("app").directive("fileread", [ function () { return { scope: { - fileread: "=" + fileread: "=", + filename: "=" }, link: function (scope, element, attributes) { - return element.bind("change", function (changeEvent) { + try { + console.log('[fileread] Directive link function called'); + console.log('[fileread] Element:', element, 'Type:', element[0] ? element[0].type : 'unknown'); + console.log('[fileread] Attributes:', attributes); + + // Add multiple event listeners for debugging + element.bind("click", function() { + console.log('[fileread] File input clicked - file picker should open'); + }); + + element.bind("focus", function() { + console.log('[fileread] File input focused'); + }); + + element.bind("blur", function() { + console.log('[fileread] File input blurred - file picker closed'); + }); + + element.bind("input", function(event) { + console.log('[fileread] Input event fired:', event); + }); + + console.log('[fileread] All event listeners bound, now binding change event'); + return element.bind("change", function (changeEvent) { + console.log('[fileread] Change event fired!', changeEvent); var reader; var file = changeEvent.target.files[0]; - if (!file) return; + console.log('[fileread] Selected file:', file); + if (!file) { + console.log('[fileread] No file selected, returning'); + return; + } reader = new FileReader(); reader.onload = function (loadEvent) { return scope.$apply(function () { - // Store both file content and filename for Excel detection - scope.fileread = loadEvent.target.result; + // Create a data object with both content and filename + var dataWithFilename = loadEvent.target.result; + + // Attach filename as a property to the data (works for both ArrayBuffer and string) + if (dataWithFilename instanceof ArrayBuffer) { + // For ArrayBuffer, create a wrapper object + dataWithFilename = { + data: loadEvent.target.result, + filename: file.name, + isArrayBuffer: true + }; + } else { + // For string data, add filename property + Object.defineProperty(dataWithFilename, '_filename', { + value: file.name, + writable: false, + enumerable: false + }); + } + + // Set both filename and data scope.filename = file.name; + scope.fileread = dataWithFilename; + + console.log('[fileread] Set data with embedded filename:', file.name); + console.log('[fileread] Data type:', dataWithFilename instanceof ArrayBuffer ? 'ArrayBuffer' : typeof dataWithFilename); + console.log('[fileread] File loaded:', file.name, 'Extension:', extension, 'isExcel:', isExcel); + + // Debug: Check what's actually in the scope after setting + setTimeout(function() { + console.log('[fileread-debug] After setting - directive scope.filename:', scope.filename); + console.log('[fileread-debug] After setting - directive scope.fileread exists:', !!scope.fileread); + }, 0); + console.log('[fileread] Setting scope.fileread to data with length:', loadEvent.target.result ? loadEvent.target.result.length || loadEvent.target.result.byteLength || 'unknown' : 'null'); + console.log('[fileread] Parent scope data.source before:', scope.$parent.data ? scope.$parent.data.source : 'undefined'); + + // Force a timeout to check if binding worked + setTimeout(function() { + scope.$apply(function() { + console.log('[fileread] After timeout - Parent scope data.source:', scope.$parent.data ? scope.$parent.data.source : 'undefined'); + console.log('[fileread] Directive scope.fileread:', scope.fileread ? 'has data' : 'no data'); + }); + }, 100); }); }; + reader.onerror = function (error) { + console.error('[fileread] FileReader error:', error); + }; + reader.onabort = function () { + console.log('[fileread] FileReader aborted'); + }; // Check if it's an Excel file - use appropriate reading method based on format // Fallback for test environment where DataObject might not be available @@ -79,19 +156,27 @@ angular.element(document).ready(function () { isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); } + console.log('[fileread] File details - Name:', file.name, 'Size:', file.size, 'Type:', file.type, 'Extension:', extension, 'isExcel:', isExcel); + if (isExcel) { // Use different reading methods for different Excel formats if (['xls', 'xlsb'].includes(extension)) { + console.log('[fileread] Reading as ArrayBuffer for XLS/XLSB'); // XLS and XLSB files use OLE2 format, read as ArrayBuffer reader.readAsArrayBuffer(file); } else { + console.log('[fileread] Reading as BinaryString for XLSX/XLSM'); // XLSX and XLSM files are ZIP-based, read as binary string reader.readAsBinaryString(file); } } else { + console.log('[fileread] Reading as Text for CSV'); reader.readAsText(file, attributes.encoding); } }); + } catch (error) { + console.error('[fileread] Error in directive link function:', error); + } } }; } @@ -103,7 +188,8 @@ angular.element(document).ready(function () { replace: true, template: '
', scope: { - dropzone: "=" + dropzone: "=", + filename: "=" }, link: function (scope, element, attributes) { element.bind("dragenter", function (event) { @@ -137,9 +223,12 @@ angular.element(document).ready(function () { reader = new FileReader(); reader.onload = function (loadEvent) { scope.$apply(function () { - // Store both file content and filename for Excel detection - scope.dropzone = loadEvent.target.result; + // Set both filename and data through proper two-way binding scope.filename = file.name; + scope.dropzone = loadEvent.target.result; + console.log('[dropzone] Set filename via binding:', file.name); + console.log('[dropzone] Set data via binding, length:', loadEvent.target.result ? loadEvent.target.result.length || loadEvent.target.result.byteLength || 'unknown' : 'null'); + console.log('[dropzone] File dropped:', file.name, 'Extension:', extension, 'isExcel:', isExcel); }); }; @@ -259,15 +348,57 @@ angular.element(document).ready(function () { $scope.profile.columnFormat = $scope.ynab_cols localStorage.setItem('profiles', JSON.stringify($scope.profiles)); }; + console.log('[controller] Setting up data.source watcher'); + + // Add a separate watcher for filename to debug binding + $scope.$watch("filename", function (newValue, oldValue) { + console.log('[filename-watcher] Filename changed from', oldValue, 'to', newValue); + }); + $scope.$watch("data.source", function (newValue, oldValue) { - if (newValue && newValue.length > 0) { + var newValueDesc = 'null/undefined'; + var filename = $scope.filename; + var actualData = newValue; + + if (newValue) { + // Check if data has embedded filename + if (newValue.isArrayBuffer && newValue.filename) { + // Excel data with embedded filename + filename = newValue.filename; + actualData = newValue.data; + newValueDesc = 'ArrayBuffer with filename (' + actualData.byteLength + ')'; + } else if (newValue instanceof ArrayBuffer) { + newValueDesc = 'ArrayBuffer(' + newValue.byteLength + ')'; + } else if (typeof newValue === 'string') { + // Check for embedded filename in string + if (newValue._filename) { + filename = newValue._filename; + } + newValueDesc = newValue.length + ' chars'; + } else if (typeof newValue === 'object' && newValue.data) { + // Fallback for wrapper objects + filename = newValue.filename || filename; + actualData = newValue.data; + newValueDesc = 'Object wrapper'; + } else { + newValueDesc = 'unknown type: ' + typeof newValue; + } + } + + console.log('[watcher] Called with newValue:', newValueDesc, 'filename from data:', filename, 'scope filename:', $scope.filename); + + // Handle both string (CSV) and ArrayBuffer (Excel) data + if (actualData && ((typeof actualData === 'string' && actualData.length > 0) || (actualData instanceof ArrayBuffer && actualData.byteLength > 0))) { + var dataLength = actualData instanceof ArrayBuffer ? actualData.byteLength : actualData.length; + console.log('[watcher] data.source changed, filename:', filename, 'data length:', dataLength); try { - // Check if this is an Excel file - if ($scope.filename && $scope.data_object.isExcelFile($scope.filename)) { - // Parse as Excel file + // Check if this is an Excel file using the extracted filename + if (filename && $scope.data_object.isExcelFile(filename)) { + console.log('[watcher] Detected Excel file:', filename); + // Parse as Excel file using the actual data $scope.data_object.parseExcel( - newValue, - $scope.filename, + actualData, + filename, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, @@ -275,11 +406,12 @@ angular.element(document).ready(function () { 0 // default to first worksheet ); } else { - // Parse as CSV file (existing logic) + console.log('[watcher] Parsing as CSV file, filename:', filename); + // Parse as CSV file (existing logic) using actualData if ($scope.file.chosenDelimiter == "auto") { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); + $scope.data_object.parseCsv(actualData, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); } else { - $scope.data_object.parseCsv(newValue, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + $scope.data_object.parseCsv(actualData, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); } } From 42a87c83b93679b6a40fbf12aa64024e4a77c5fa Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 14:33:48 +0200 Subject: [PATCH 10/35] Clean up debug code and fix CSV functionality and tests - Removed verbose debug logging while keeping essential error handling - Fixed CSV functionality by implementing filename embedding for both fileread and dropzone directives - Updated tests to handle new data structure with embedded filenames - Added fallback for Object.defineProperty in test environments - All tests now pass and both CSV and Excel file processing work correctly Changes: - Clean directive code with minimal logging - Consistent filename embedding approach across both directives - Updated test expectations to match new data wrapper structure - Robust error handling for property definition edge cases --- src/app.js | 150 ++++++++++++++------------------------- tests/directives.test.js | 13 +++- 2 files changed, 63 insertions(+), 100 deletions(-) diff --git a/src/app.js b/src/app.js index ed63a25..90fbb44 100644 --- a/src/app.js +++ b/src/app.js @@ -44,9 +44,7 @@ Date.prototype.yyyymmdd = function () { }; angular.element(document).ready(function () { - console.log('[app] Angular app starting to load...'); angular.module("app", []); - console.log('[app] Angular module created'); angular.module("app").directive("fileread", [ function () { return { @@ -56,37 +54,10 @@ angular.element(document).ready(function () { }, link: function (scope, element, attributes) { try { - console.log('[fileread] Directive link function called'); - console.log('[fileread] Element:', element, 'Type:', element[0] ? element[0].type : 'unknown'); - console.log('[fileread] Attributes:', attributes); - - // Add multiple event listeners for debugging - element.bind("click", function() { - console.log('[fileread] File input clicked - file picker should open'); - }); - - element.bind("focus", function() { - console.log('[fileread] File input focused'); - }); - - element.bind("blur", function() { - console.log('[fileread] File input blurred - file picker closed'); - }); - - element.bind("input", function(event) { - console.log('[fileread] Input event fired:', event); - }); - - console.log('[fileread] All event listeners bound, now binding change event'); return element.bind("change", function (changeEvent) { - console.log('[fileread] Change event fired!', changeEvent); var reader; var file = changeEvent.target.files[0]; - console.log('[fileread] Selected file:', file); - if (!file) { - console.log('[fileread] No file selected, returning'); - return; - } + if (!file) return; reader = new FileReader(); reader.onload = function (loadEvent) { @@ -102,45 +73,31 @@ angular.element(document).ready(function () { filename: file.name, isArrayBuffer: true }; - } else { + } else if (typeof dataWithFilename === 'string') { // For string data, add filename property - Object.defineProperty(dataWithFilename, '_filename', { - value: file.name, - writable: false, - enumerable: false - }); + try { + Object.defineProperty(dataWithFilename, '_filename', { + value: file.name, + writable: false, + enumerable: false + }); + } catch (e) { + // Fallback: create wrapper object for strings that can't have properties + dataWithFilename = { + data: loadEvent.target.result, + filename: file.name, + isString: true + }; + } } // Set both filename and data scope.filename = file.name; scope.fileread = dataWithFilename; - - console.log('[fileread] Set data with embedded filename:', file.name); - console.log('[fileread] Data type:', dataWithFilename instanceof ArrayBuffer ? 'ArrayBuffer' : typeof dataWithFilename); - console.log('[fileread] File loaded:', file.name, 'Extension:', extension, 'isExcel:', isExcel); - - // Debug: Check what's actually in the scope after setting - setTimeout(function() { - console.log('[fileread-debug] After setting - directive scope.filename:', scope.filename); - console.log('[fileread-debug] After setting - directive scope.fileread exists:', !!scope.fileread); - }, 0); - console.log('[fileread] Setting scope.fileread to data with length:', loadEvent.target.result ? loadEvent.target.result.length || loadEvent.target.result.byteLength || 'unknown' : 'null'); - console.log('[fileread] Parent scope data.source before:', scope.$parent.data ? scope.$parent.data.source : 'undefined'); - - // Force a timeout to check if binding worked - setTimeout(function() { - scope.$apply(function() { - console.log('[fileread] After timeout - Parent scope data.source:', scope.$parent.data ? scope.$parent.data.source : 'undefined'); - console.log('[fileread] Directive scope.fileread:', scope.fileread ? 'has data' : 'no data'); - }); - }, 100); }); }; reader.onerror = function (error) { - console.error('[fileread] FileReader error:', error); - }; - reader.onabort = function () { - console.log('[fileread] FileReader aborted'); + console.error('FileReader error:', error); }; // Check if it's an Excel file - use appropriate reading method based on format @@ -156,26 +113,21 @@ angular.element(document).ready(function () { isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); } - console.log('[fileread] File details - Name:', file.name, 'Size:', file.size, 'Type:', file.type, 'Extension:', extension, 'isExcel:', isExcel); - if (isExcel) { // Use different reading methods for different Excel formats if (['xls', 'xlsb'].includes(extension)) { - console.log('[fileread] Reading as ArrayBuffer for XLS/XLSB'); // XLS and XLSB files use OLE2 format, read as ArrayBuffer reader.readAsArrayBuffer(file); } else { - console.log('[fileread] Reading as BinaryString for XLSX/XLSM'); // XLSX and XLSM files are ZIP-based, read as binary string reader.readAsBinaryString(file); } } else { - console.log('[fileread] Reading as Text for CSV'); reader.readAsText(file, attributes.encoding); } }); } catch (error) { - console.error('[fileread] Error in directive link function:', error); + console.error('Error in fileread directive:', error); } } }; @@ -223,12 +175,38 @@ angular.element(document).ready(function () { reader = new FileReader(); reader.onload = function (loadEvent) { scope.$apply(function () { - // Set both filename and data through proper two-way binding + // Create a data object with both content and filename + var dataWithFilename = loadEvent.target.result; + + // Attach filename as a property to the data (works for both ArrayBuffer and string) + if (dataWithFilename instanceof ArrayBuffer) { + // For ArrayBuffer, create a wrapper object + dataWithFilename = { + data: loadEvent.target.result, + filename: file.name, + isArrayBuffer: true + }; + } else if (typeof dataWithFilename === 'string') { + // For string data, add filename property + try { + Object.defineProperty(dataWithFilename, '_filename', { + value: file.name, + writable: false, + enumerable: false + }); + } catch (e) { + // Fallback: create wrapper object for strings that can't have properties + dataWithFilename = { + data: loadEvent.target.result, + filename: file.name, + isString: true + }; + } + } + + // Set both filename and data scope.filename = file.name; - scope.dropzone = loadEvent.target.result; - console.log('[dropzone] Set filename via binding:', file.name); - console.log('[dropzone] Set data via binding, length:', loadEvent.target.result ? loadEvent.target.result.length || loadEvent.target.result.byteLength || 'unknown' : 'null'); - console.log('[dropzone] File dropped:', file.name, 'Extension:', extension, 'isExcel:', isExcel); + scope.dropzone = dataWithFilename; }); }; @@ -348,15 +326,8 @@ angular.element(document).ready(function () { $scope.profile.columnFormat = $scope.ynab_cols localStorage.setItem('profiles', JSON.stringify($scope.profiles)); }; - console.log('[controller] Setting up data.source watcher'); - - // Add a separate watcher for filename to debug binding - $scope.$watch("filename", function (newValue, oldValue) { - console.log('[filename-watcher] Filename changed from', oldValue, 'to', newValue); - }); $scope.$watch("data.source", function (newValue, oldValue) { - var newValueDesc = 'null/undefined'; var filename = $scope.filename; var actualData = newValue; @@ -366,35 +337,21 @@ angular.element(document).ready(function () { // Excel data with embedded filename filename = newValue.filename; actualData = newValue.data; - newValueDesc = 'ArrayBuffer with filename (' + actualData.byteLength + ')'; - } else if (newValue instanceof ArrayBuffer) { - newValueDesc = 'ArrayBuffer(' + newValue.byteLength + ')'; - } else if (typeof newValue === 'string') { - // Check for embedded filename in string - if (newValue._filename) { - filename = newValue._filename; - } - newValueDesc = newValue.length + ' chars'; + } else if (typeof newValue === 'string' && newValue._filename) { + // CSV data with embedded filename + filename = newValue._filename; } else if (typeof newValue === 'object' && newValue.data) { - // Fallback for wrapper objects + // Wrapper objects (for strings that couldn't have properties added, or other wrapper types) filename = newValue.filename || filename; actualData = newValue.data; - newValueDesc = 'Object wrapper'; - } else { - newValueDesc = 'unknown type: ' + typeof newValue; } } - console.log('[watcher] Called with newValue:', newValueDesc, 'filename from data:', filename, 'scope filename:', $scope.filename); - // Handle both string (CSV) and ArrayBuffer (Excel) data if (actualData && ((typeof actualData === 'string' && actualData.length > 0) || (actualData instanceof ArrayBuffer && actualData.byteLength > 0))) { - var dataLength = actualData instanceof ArrayBuffer ? actualData.byteLength : actualData.length; - console.log('[watcher] data.source changed, filename:', filename, 'data length:', dataLength); try { // Check if this is an Excel file using the extracted filename if (filename && $scope.data_object.isExcelFile(filename)) { - console.log('[watcher] Detected Excel file:', filename); // Parse as Excel file using the actual data $scope.data_object.parseExcel( actualData, @@ -406,7 +363,6 @@ angular.element(document).ready(function () { 0 // default to first worksheet ); } else { - console.log('[watcher] Parsing as CSV file, filename:', filename); // Parse as CSV file (existing logic) using actualData if ($scope.file.chosenDelimiter == "auto") { $scope.data_object.parseCsv(actualData, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); diff --git a/tests/directives.test.js b/tests/directives.test.js index c1ceb6e..4f6176f 100644 --- a/tests/directives.test.js +++ b/tests/directives.test.js @@ -59,7 +59,8 @@ describe('AngularJS Directives', () => { const directiveConfig = directiveFactory(); expect(directiveConfig.scope).toEqual({ - fileread: "=" + fileread: "=", + filename: "=" }); }); @@ -123,7 +124,12 @@ describe('AngularJS Directives', () => { mockReader.onload(loadEvent); expect(mockScope.$apply).toHaveBeenCalled(); - expect(mockScope.fileread).toBe('csv,data\ntest,value'); + // For CSV files, the data is wrapped in an object with filename + expect(mockScope.fileread).toEqual({ + data: 'csv,data\ntest,value', + filename: 'test.csv', + isString: true + }); }); }); @@ -168,7 +174,8 @@ describe('AngularJS Directives', () => { expect(directiveConfig.replace).toBe(true); expect(directiveConfig.template).toBe('
'); expect(directiveConfig.scope).toEqual({ - dropzone: "=" + dropzone: "=", + filename: "=" }); }); From e9a545e7cca5edb9fd449872997a5808d4ebc62a Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 14:37:09 +0200 Subject: [PATCH 11/35] Clean up console.error output in tests - Mock console.error in Excel parsing error tests to eliminate noise - Verify that error logging actually works as expected - Maintain error logging behavior in production code - All tests pass with clean output Added console.error mocking to 3 error tests: - Excel parsing errors (invalid file) - Empty workbook handling - Invalid worksheet index handling --- tests/data_object.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/data_object.test.js b/tests/data_object.test.js index ca51de2..2d39e22 100644 --- a/tests/data_object.test.js +++ b/tests/data_object.test.js @@ -531,6 +531,8 @@ describe('DataObject', () => { }); test('should handle Excel parsing errors', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + global.XLSX.read.mockImplementation(() => { throw new Error('Invalid Excel file'); }); @@ -538,9 +540,14 @@ describe('DataObject', () => { expect(() => { dataObject.parseExcel('invalid_data', 'test.xlsx', 'UTF-8'); }).toThrow('Failed to parse Excel file: Invalid Excel file'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); }); test('should handle empty workbook', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockWorkbook = { SheetNames: [], Sheets: {} @@ -551,9 +558,14 @@ describe('DataObject', () => { expect(() => { dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); }).toThrow('Failed to parse Excel file: No worksheets found in Excel file'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); }); test('should handle missing worksheet', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + const mockWorkbook = { SheetNames: ['Sheet1'], Sheets: { @@ -566,6 +578,9 @@ describe('DataObject', () => { expect(() => { dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 5); // invalid index }).toThrow('Failed to parse Excel file: Worksheet index 5 is out of range. Available sheets: 1'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); }); test('should pass through all parseCsv parameters', () => { From 08ee1ac0844bc04eefbb6ed0c0208d9fafe12e57 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 14:41:06 +0200 Subject: [PATCH 12/35] Add Excel files to .gitignore to prevent accidental commits - Prevent accidental commits of sensitive Excel files containing financial data - Covers all Excel formats: .xls, .xlsx, .xlsm, .xlsb - Allows keeping test files locally without risk of committing them --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 0bd0f7f..a79409b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ node_modules *.log .vscode + +# Excel files (to prevent accidental commits of sensitive data) +*.xls +*.xlsx +*.xlsm +*.xlsb From 1fe0a1f6b82f50e1ab8b217b6ecb8bedc2bc7ef1 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 14:45:08 +0200 Subject: [PATCH 13/35] Update UI to show correct file type instead of hardcoded 'CSV' - Change 'Imported CSV' to 'Imported {{getFileType(filename)}}' - Now displays 'Imported XLS', 'Imported XLSX', 'Imported CSV' etc based on actual file type - Uses existing getFileType() function for consistency with file type badge - Provides more accurate information to users about what file was processed --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index f8723b0..181d31d 100644 --- a/index.html +++ b/index.html @@ -233,7 +233,7 @@

YNAB Data (first 10 rows)
- Imported CSV (first 10 rows) + Imported {{getFileType(filename)}} (first 10 rows)
From d490ceb4bfdcf1e1d2799dc451c43d76a656399f Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Fri, 25 Jul 2025 20:14:13 +0200 Subject: [PATCH 14/35] Add Excel test coverage and clean up console errors - Add comprehensive test coverage for Excel functionality - Mock console.error in error handling tests to clean output - Add tests for XLS/XLSB ArrayBuffer handling - Add tests for worksheet selection and Excel file detection - Add tests for Excel file processing integration - Remove trailing whitespace from test files - Add coverage/ to .gitignore --- .gitignore | 1 + tests/app.test.js | 407 +++++++++++++++++++++++++++++++++++++- tests/data_object.test.js | 162 ++++++++++++++- tests/directives.test.js | 2 + 4 files changed, 570 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a79409b..41b9f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules *.log .vscode +coverage/ # Excel files (to prevent accidental commits of sensitive data) *.xls diff --git a/tests/app.test.js b/tests/app.test.js index 80de031..aadef47 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -16,10 +16,14 @@ global.angular = { // Mock DataObject global.DataObject = jest.fn(() => ({ parseCsv: jest.fn(), + parseExcel: jest.fn(), + isExcelFile: jest.fn(), converted_json: jest.fn(), converted_csv: jest.fn(), fields: jest.fn(() => []), - rows: jest.fn(() => []) + rows: jest.fn(() => []), + worksheetNames: [], + currentWorksheet: null })); // Mock document @@ -345,4 +349,405 @@ describe('ParseController', () => { global.Date.mockRestore(); }); }); + + describe('Excel Helper Functions', () => { + test('isExcelFile should detect Excel files correctly', () => { + expect($scope.isExcelFile('test.xlsx')).toBe(true); + expect($scope.isExcelFile('test.xls')).toBe(true); + expect($scope.isExcelFile('test.xlsm')).toBe(true); + expect($scope.isExcelFile('test.xlsb')).toBe(true); + expect($scope.isExcelFile('TEST.XLSX')).toBe(true); // case insensitive + expect($scope.isExcelFile('document.xlsx')).toBe(true); + }); + + test('isExcelFile should reject non-Excel files', () => { + expect($scope.isExcelFile('test.csv')).toBe(false); + expect($scope.isExcelFile('test.txt')).toBe(false); + expect($scope.isExcelFile('test.pdf')).toBe(false); + expect($scope.isExcelFile('document.doc')).toBe(false); + expect($scope.isExcelFile('file.json')).toBe(false); + }); + + test('isExcelFile should handle edge cases', () => { + expect($scope.isExcelFile('')).toBe(false); + expect($scope.isExcelFile(null)).toBe(false); + expect($scope.isExcelFile(undefined)).toBe(false); + expect($scope.isExcelFile('file')).toBe(false); // no extension + expect($scope.isExcelFile('file.')).toBe(false); // empty extension + expect($scope.isExcelFile('.xlsx')).toBe(true); // hidden file + }); + + test('getFileType should return correct format strings for Excel files', () => { + expect($scope.getFileType('test.xlsx')).toBe('XLSX'); + expect($scope.getFileType('test.xls')).toBe('XLS'); + expect($scope.getFileType('test.xlsm')).toBe('XLSM'); + expect($scope.getFileType('test.xlsb')).toBe('XLSB'); + expect($scope.getFileType('TEST.XLSX')).toBe('XLSX'); // case insensitive + }); + + test('getFileType should return CSV for non-Excel files', () => { + expect($scope.getFileType('test.csv')).toBe('CSV'); + expect($scope.getFileType('test.txt')).toBe('CSV'); + expect($scope.getFileType('test.pdf')).toBe('CSV'); + expect($scope.getFileType('document.doc')).toBe('CSV'); + }); + + test('getFileType should handle edge cases', () => { + expect($scope.getFileType('')).toBe(''); + expect($scope.getFileType(null)).toBe(''); + expect($scope.getFileType(undefined)).toBe(''); + expect($scope.getFileType('file')).toBe('CSV'); // no extension + expect($scope.getFileType('file.')).toBe('CSV'); // empty extension + }); + }); + + describe('Worksheet Selection', () => { + beforeEach(() => { + // Set up Excel file scenario + $scope.filename = 'test.xlsx'; + $scope.data = { source: 'excel_binary_data' }; + $scope.data_object.parseExcel = jest.fn(); + $scope.data_object.converted_json = jest.fn(() => [ + { Date: '2024-01-01', Payee: 'Excel Store', Amount: '-75.00' } + ]); + }); + + test('worksheetChosen should re-parse Excel with new worksheet index', () => { + $scope.worksheetChosen(2); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + 'excel_binary_data', + 'test.xlsx', + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + null, // auto delimiter becomes null + 2 // worksheet index + ); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow + ); + }); + + test('worksheetChosen should handle custom delimiter', () => { + $scope.file.chosenDelimiter = ';'; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + 'excel_binary_data', + 'test.xlsx', + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ';', // custom delimiter + 1 + ); + }); + + test('worksheetChosen should handle parseExcel errors gracefully', () => { + // Mock console.error and alert to avoid noise in tests + global.console.error = jest.fn(); + global.alert = jest.fn(); + + // Make parseExcel throw an error + $scope.data_object.parseExcel.mockImplementation(() => { + throw new Error('Invalid worksheet'); + }); + + // This should not throw + expect(() => { + $scope.worksheetChosen(5); + }).not.toThrow(); + + expect(global.console.error).toHaveBeenCalledWith('Error switching worksheet:', expect.any(Error)); + expect(global.alert).toHaveBeenCalledWith('Error switching worksheet: Invalid worksheet'); + + // Clean up mocks + delete global.console.error; + delete global.alert; + }); + + test('worksheetChosen should do nothing if filename is not set', () => { + $scope.filename = null; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + + test('worksheetChosen should do nothing if data.source is not set', () => { + $scope.data.source = null; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + + test('worksheetChosen should do nothing for non-Excel files', () => { + $scope.filename = 'test.csv'; + + $scope.worksheetChosen(1); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.converted_json).not.toHaveBeenCalled(); + }); + }); + + describe('Excel File Processing Integration', () => { + beforeEach(() => { + // Set up data object mock methods for Excel + $scope.data_object.parseExcel = jest.fn(); + $scope.data_object.isExcelFile = jest.fn(); + $scope.data_object.converted_json = jest.fn(() => [ + { Date: '2024-01-01', Payee: 'Excel Store', Amount: '-75.00' } + ]); + $scope.data_object.worksheetNames = ['Sheet1', 'Data']; + }); + + test('data.source watcher should call parseExcel for Excel files', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.filename = 'test.xlsx'; + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.file.chosenDelimiter = 'auto'; + $scope.file.startAtRow = 2; + $scope.profile.extraRow = true; + + const excelData = 'excel_binary_data'; + + // Simulate Excel file data change + watchCallbacks['data.source'](excelData, null); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + excelData, + 'test.xlsx', + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + null, // auto delimiter becomes null + 0 // default worksheet index + ); + + expect($scope.data_object.converted_json).toHaveBeenCalledWith( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow + ); + + // Verify worksheet initialization + expect($scope.file.selectedWorksheet).toBe(0); + }); + + test('data.source watcher should call parseExcel with custom delimiter', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario with custom delimiter + $scope.filename = 'test.xlsx'; + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.file.chosenDelimiter = ';'; + + const excelData = 'excel_binary_data'; + + watchCallbacks['data.source'](excelData, null); + + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( + excelData, + 'test.xlsx', + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ';', // custom delimiter + 0 + ); + }); + + test('data.source watcher should call parseCsv for CSV files', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up CSV file scenario + $scope.filename = 'test.csv'; + $scope.data_object.isExcelFile.mockReturnValue(false); + $scope.file.chosenDelimiter = 'auto'; + + const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; + + watchCallbacks['data.source'](csvData, null); + + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( + csvData, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow + ); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + }); + + test('data.source watcher should handle Excel parsing errors', () => { + // Mock console.error and alert to avoid noise in tests + global.console.error = jest.fn(); + global.alert = jest.fn(); + + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.filename = 'test.xlsx'; + $scope.data_object.isExcelFile.mockReturnValue(true); + + // Make parseExcel throw an error + $scope.data_object.parseExcel.mockImplementation(() => { + throw new Error('Corrupted Excel file'); + }); + + const excelData = 'corrupted_excel_data'; + + // This should not crash the watcher + expect(() => { + watchCallbacks['data.source'](excelData, null); + }).not.toThrow(); + + expect(global.console.error).toHaveBeenCalledWith('Error parsing file:', expect.any(Error)); + expect(global.alert).toHaveBeenCalledWith('Error parsing file: Corrupted Excel file'); + + // Clean up mocks + delete global.console.error; + delete global.alert; + }); + + test('data.source watcher should not process empty data', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Test empty data scenarios + watchCallbacks['data.source']('', null); + watchCallbacks['data.source'](null, null); + watchCallbacks['data.source'](undefined, null); + + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); + expect($scope.data_object.parseCsv).not.toHaveBeenCalled(); + }); + + test('worksheet initialization should work for Excel files', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up Excel file scenario + $scope.filename = 'test.xlsx'; + $scope.data_object.isExcelFile.mockReturnValue(true); + $scope.data_object.worksheetNames = ['Sheet1', 'Data', 'Summary']; + + const excelData = 'excel_binary_data'; + + watchCallbacks['data.source'](excelData, null); + + // Should initialize selectedWorksheet to 0 for multi-sheet Excel files + expect($scope.file.selectedWorksheet).toBe(0); + }); + + test('worksheet initialization should not occur for CSV files', () => { + // Set up watchers + const watchCallbacks = {}; + $scope.$watch.mockImplementation((expr, callback) => { + watchCallbacks[expr] = callback; + }); + + // Re-initialize to capture watch callbacks + jest.resetModules(); + require('../src/app.js'); + const controllerCalls = mockModule.controller.mock.calls; + const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const controllerFn = parseControllerCall[1]; + controllerFn($scope, $location); + + // Set up CSV file scenario + $scope.filename = 'test.csv'; + $scope.data_object.isExcelFile.mockReturnValue(false); + + const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; + + watchCallbacks['data.source'](csvData, null); + + // Should not set selectedWorksheet for CSV files + expect($scope.file.selectedWorksheet).toBeUndefined(); + }); + }); }); \ No newline at end of file diff --git a/tests/data_object.test.js b/tests/data_object.test.js index 2d39e22..ea1f6a1 100644 --- a/tests/data_object.test.js +++ b/tests/data_object.test.js @@ -600,6 +600,166 @@ describe('DataObject', () => { expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'ISO-8859-1', 3, true, ','); }); + + test('should handle XLS files with ArrayBuffer data type', () => { + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Date,Amount\n2024-01-01,-50.00'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with ArrayBuffer for XLS file + const arrayBuffer = new ArrayBuffer(100); + dataObject.parseExcel(arrayBuffer, 'test.xls', 'UTF-8'); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { type: 'array' }); + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + }); + + test('should handle XLSB files with ArrayBuffer data type', () => { + const mockWorkbook = { + SheetNames: ['Data'], + Sheets: { + 'Data': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Name,Value\nTest,123'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with ArrayBuffer for XLSB file + const arrayBuffer = new ArrayBuffer(200); + dataObject.parseExcel(arrayBuffer, 'data.xlsb', 'UTF-8'); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { type: 'array' }); + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + }); + + test('should handle XLSX files with binary string data type', () => { + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Header1,Header2\nValue1,Value2'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with binary string for XLSX file + const binaryString = 'binary_data_string'; + dataObject.parseExcel(binaryString, 'spreadsheet.xlsx', 'UTF-8'); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { type: 'binary' }); + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + }); + + test('should handle XLSM files with binary string data type', () => { + const mockWorkbook = { + SheetNames: ['Macros'], + Sheets: { + 'Macros': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Function,Result\nSUM,100'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test with binary string for XLSM file + const binaryString = 'xlsm_binary_data'; + dataObject.parseExcel(binaryString, 'macro_file.xlsm', 'UTF-8'); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { type: 'binary' }); + expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + }); + + test('should handle corrupted Excel file with meaningful error', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + global.XLSX.read.mockImplementation(() => { + throw new Error('Corrupted file structure'); + }); + + expect(() => { + dataObject.parseExcel('corrupted_data', 'test.xlsx', 'UTF-8'); + }).toThrow('Failed to parse Excel file: Corrupted file structure'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + + test('should handle empty worksheet gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const mockWorkbook = { + SheetNames: ['EmptySheet'], + Sheets: { + 'EmptySheet': null + } + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); + }).toThrow('Failed to parse Excel file: Worksheet not found: EmptySheet'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + + test('should handle worksheet with special characters in name', () => { + const mockWorkbook = { + SheetNames: ['Data & Analysis', 'Summary (Final)'], + Sheets: { + 'Data & Analysis': { /* worksheet data */ }, + 'Summary (Final)': { /* worksheet data */ } + } + }; + const mockCsvContent = 'Special,Data\nTest,Value'; + + global.XLSX.read.mockReturnValue(mockWorkbook); + global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); + dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); + + // Test selecting worksheet with special characters + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 1); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Summary (Final)']); + expect(dataObject.currentWorksheet).toBe('Summary (Final)'); + }); + + test('should handle very large worksheet index gracefully', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + const mockWorkbook = { + SheetNames: ['Sheet1'], + Sheets: { + 'Sheet1': { /* worksheet data */ } + } + }; + + global.XLSX.read.mockReturnValue(mockWorkbook); + + expect(() => { + dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 999); + }).toThrow('Failed to parse Excel file: Worksheet index 999 is out of range. Available sheets: 1'); + + expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + consoleSpy.mockRestore(); + }); }); }); -}); \ No newline at end of file +}); diff --git a/tests/directives.test.js b/tests/directives.test.js index 4f6176f..2254ed8 100644 --- a/tests/directives.test.js +++ b/tests/directives.test.js @@ -340,4 +340,6 @@ describe('AngularJS Directives', () => { expect(mockScope.$apply).not.toHaveBeenCalled(); }); }); + + // TODO: Add Excel directive tests (currently complex due to sophisticated file handling logic) }); \ No newline at end of file From 5fbd085551a26f3315e06075b3491a44ea11f958 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 09:13:35 +0200 Subject: [PATCH 15/35] Fix Excel worksheet selection bug for multi-sheet files - Store original filename when Excel files are parsed to persist across sessions - Update worksheetChosen function to use stored filename when scope.filename is null - Move worksheet selector from hidden upload form to visible data preview section - Fix worksheet selection initialization and data extraction from wrapper objects - Clean up trailing whitespace in modified files Resolves issue where users could not switch between worksheets after initial upload. --- index.html | 29 ++++++++++++++++------------- src/app.js | 44 +++++++++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/index.html b/index.html index 181d31d..9a51434 100644 --- a/index.html +++ b/index.html @@ -152,19 +152,6 @@ -
- - -
@@ -173,6 +160,22 @@
+ +
+ + +
+
diff --git a/src/app.js b/src/app.js index 90fbb44..f0918df 100644 --- a/src/app.js +++ b/src/app.js @@ -284,7 +284,8 @@ angular.element(document).ready(function () { chosenEncoding: $scope.profile.chosenEncoding || "UTF-8", chosenDelimiter: $scope.profile.chosenDelimiter || "auto", startAtRow: $scope.profile.startAtRow, - extraRow: $scope.profile.extraRow || false + extraRow: $scope.profile.extraRow || false, + selectedWorksheet: 0 // Use index as source of truth }; $scope.data_object = new DataObject(); $scope.filename = null; @@ -352,6 +353,8 @@ angular.element(document).ready(function () { try { // Check if this is an Excel file using the extracted filename if (filename && $scope.data_object.isExcelFile(filename)) { + // Store the original filename for later use in worksheet switching + $scope.originalFilename = filename; // Parse as Excel file using the actual data $scope.data_object.parseExcel( actualData, @@ -372,8 +375,10 @@ angular.element(document).ready(function () { } // Initialize worksheet selection for Excel files - if ($scope.filename && $scope.isExcelFile($scope.filename) && $scope.data_object.worksheetNames) { - $scope.file.selectedWorksheet = 0; // Default to first worksheet + if ($scope.filename && $scope.isExcelFile($scope.filename) && $scope.data_object.worksheetNames && $scope.data_object.worksheetNames.length > 0) { + $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) + // Force Angular to update the binding + $scope.$evalAsync(); } $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); @@ -422,22 +427,39 @@ angular.element(document).ready(function () { } return 'CSV'; }; - + // Handle worksheet selection for Excel files $scope.worksheetChosen = function(worksheetIndex) { - if ($scope.filename && $scope.data.source && $scope.isExcelFile($scope.filename)) { + if (($scope.filename || $scope.originalFilename) && $scope.data.source && $scope.isExcelFile($scope.filename || $scope.originalFilename)) { try { + var actualData = $scope.data.source; + + // Extract actual data from wrapper objects (same logic as in $watch) + if ($scope.data.source.isArrayBuffer && $scope.data.source.data) { + actualData = $scope.data.source.data; + } else if ($scope.data.source.isString && $scope.data.source.data) { + actualData = $scope.data.source.data; + } else if (typeof $scope.data.source === 'object' && $scope.data.source.data) { + actualData = $scope.data.source.data; + } + + // Convert to number if it's a string (from ng-value) + var index = typeof worksheetIndex === 'string' ? parseInt(worksheetIndex, 10) : worksheetIndex; + // Re-parse the Excel file with the selected worksheet $scope.data_object.parseExcel( - $scope.data.source, - $scope.filename, - $scope.file.chosenEncoding, - $scope.file.startAtRow, - $scope.profile.extraRow, + actualData, + $scope.filename || $scope.originalFilename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, - worksheetIndex + index ); + $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); + // Force Angular to update the view + $scope.$evalAsync(); } catch (error) { console.error('Error switching worksheet:', error); alert('Error switching worksheet: ' + error.message); From 46fe3a74129792d813da452ecdf2ea0753b2e072 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 09:44:06 +0200 Subject: [PATCH 16/35] Refactor file processing architecture for unified data handling - Add shared helper functions for consistent file processing across directives - Extract common processFile function eliminating ~120 lines of duplicated code - Simplify fileread and dropzone directives to use unified file processing - Refactor controller data handling to use consistent wrapper format - Update worksheetChosen function to remove data extraction duplication - Replace mixed filename/originalFilename approach with single currentFilename - Update all tests to use new unified data structure format - Clean up trailing whitespace in all modified files This major refactor improves maintainability by consolidating file processing logic into shared functions while maintaining all existing functionality. --- src/app.js | 272 ++++++++++++--------------------------- tests/app.test.js | 69 ++++++---- tests/directives.test.js | 3 +- 3 files changed, 127 insertions(+), 217 deletions(-) diff --git a/src/app.js b/src/app.js index f0918df..f4e4570 100644 --- a/src/app.js +++ b/src/app.js @@ -44,6 +44,54 @@ Date.prototype.yyyymmdd = function () { }; angular.element(document).ready(function () { + // Shared helper functions for file processing + function createFileDataWrapper(content, filename) { + return { + data: content, + filename: filename + }; + } + + function getExcelReadingMethod(filename) { + var extension = filename.toLowerCase().split('.').pop(); + return ['xls', 'xlsb'].includes(extension) ? 'arrayBuffer' : 'binaryString'; + } + + function isExcelFileHelper(filename) { + if (!filename) return false; + if (window.DataObject) { + return new window.DataObject().isExcelFile(filename); + } + var extension = filename.toLowerCase().split('.').pop(); + return ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); + } + + function processFile(file, scope, targetProperty, attributes) { + var reader = new FileReader(); + + reader.onload = function(loadEvent) { + scope.$apply(function() { + var fileData = createFileDataWrapper(loadEvent.target.result, file.name); + scope.filename = file.name; + scope[targetProperty] = fileData; + }); + }; + + reader.onerror = function(error) { + console.error('FileReader error:', error); + }; + if (isExcelFileHelper(file.name)) { + var method = getExcelReadingMethod(file.name); + if (method === 'arrayBuffer') { + reader.readAsArrayBuffer(file); + } else { + reader.readAsBinaryString(file); + } + } else { + reader.readAsText(file, attributes.encoding); + } + } + angular.module("app", []); angular.module("app").directive("fileread", [ function () { @@ -54,78 +102,12 @@ angular.element(document).ready(function () { }, link: function (scope, element, attributes) { try { - return element.bind("change", function (changeEvent) { - var reader; - var file = changeEvent.target.files[0]; - if (!file) return; - - reader = new FileReader(); - reader.onload = function (loadEvent) { - return scope.$apply(function () { - // Create a data object with both content and filename - var dataWithFilename = loadEvent.target.result; - - // Attach filename as a property to the data (works for both ArrayBuffer and string) - if (dataWithFilename instanceof ArrayBuffer) { - // For ArrayBuffer, create a wrapper object - dataWithFilename = { - data: loadEvent.target.result, - filename: file.name, - isArrayBuffer: true - }; - } else if (typeof dataWithFilename === 'string') { - // For string data, add filename property - try { - Object.defineProperty(dataWithFilename, '_filename', { - value: file.name, - writable: false, - enumerable: false - }); - } catch (e) { - // Fallback: create wrapper object for strings that can't have properties - dataWithFilename = { - data: loadEvent.target.result, - filename: file.name, - isString: true - }; - } - } - - // Set both filename and data - scope.filename = file.name; - scope.fileread = dataWithFilename; - }); - }; - reader.onerror = function (error) { - console.error('FileReader error:', error); - }; - - // Check if it's an Excel file - use appropriate reading method based on format - // Fallback for test environment where DataObject might not be available - var isExcel = false; - var extension = file.name.toLowerCase().split('.').pop(); - - if (window.DataObject) { - var dataObject = new window.DataObject(); - isExcel = dataObject.isExcelFile(file.name); - } else { - // Simple fallback for testing - isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); - } - - if (isExcel) { - // Use different reading methods for different Excel formats - if (['xls', 'xlsb'].includes(extension)) { - // XLS and XLSB files use OLE2 format, read as ArrayBuffer - reader.readAsArrayBuffer(file); - } else { - // XLSX and XLSM files are ZIP-based, read as binary string - reader.readAsBinaryString(file); - } - } else { - reader.readAsText(file, attributes.encoding); - } - }); + element.bind("change", function (changeEvent) { + var file = changeEvent.target.files[0]; + if (!file) return; + + processFile(file, scope, 'fileread', attributes); + }); } catch (error) { console.error('Error in fileread directive:', error); } @@ -164,7 +146,6 @@ angular.element(document).ready(function () { event.preventDefault(); }); element.bind("drop", function (event) { - var reader; element.removeClass("dragging"); event.preventDefault(); event.stopPropagation(); @@ -172,72 +153,11 @@ angular.element(document).ready(function () { var file = (event.dataTransfer || event.originalEvent.dataTransfer).files[0]; if (!file) return; - reader = new FileReader(); - reader.onload = function (loadEvent) { - scope.$apply(function () { - // Create a data object with both content and filename - var dataWithFilename = loadEvent.target.result; - - // Attach filename as a property to the data (works for both ArrayBuffer and string) - if (dataWithFilename instanceof ArrayBuffer) { - // For ArrayBuffer, create a wrapper object - dataWithFilename = { - data: loadEvent.target.result, - filename: file.name, - isArrayBuffer: true - }; - } else if (typeof dataWithFilename === 'string') { - // For string data, add filename property - try { - Object.defineProperty(dataWithFilename, '_filename', { - value: file.name, - writable: false, - enumerable: false - }); - } catch (e) { - // Fallback: create wrapper object for strings that can't have properties - dataWithFilename = { - data: loadEvent.target.result, - filename: file.name, - isString: true - }; - } - } - - // Set both filename and data - scope.filename = file.name; - scope.dropzone = dataWithFilename; - }); - }; - - // Check if it's an Excel file - use appropriate reading method based on format - // Fallback for test environment where DataObject might not be available - var isExcel = false; - var extension = file.name.toLowerCase().split('.').pop(); - - if (window.DataObject) { - var dataObject = new window.DataObject(); - isExcel = dataObject.isExcelFile(file.name); - } else { - // Simple fallback for testing - isExcel = ['xlsx', 'xls', 'xlsm', 'xlsb'].includes(extension); - } - - if (isExcel) { - // Use different reading methods for different Excel formats - if (['xls', 'xlsb'].includes(extension)) { - // XLS and XLSB files use OLE2 format, read as ArrayBuffer - reader.readAsArrayBuffer(file); - } else { - // XLSX and XLSM files are ZIP-based, read as binary string - reader.readAsBinaryString(file); - } - } else { - reader.readAsText(file, attributes.encoding); - } + processFile(file, scope, 'dropzone', attributes); }); element.bind("paste", function (event) { var items = (event.clipboardData || event.originalEvent.clipboardData).items; + var data; for (var i = 0; i < items.length; i++) { if (items[i].type == 'text/plain') { data = items[i]; @@ -329,58 +249,38 @@ angular.element(document).ready(function () { }; $scope.$watch("data.source", function (newValue, oldValue) { - var filename = $scope.filename; - var actualData = newValue; - - if (newValue) { - // Check if data has embedded filename - if (newValue.isArrayBuffer && newValue.filename) { - // Excel data with embedded filename - filename = newValue.filename; - actualData = newValue.data; - } else if (typeof newValue === 'string' && newValue._filename) { - // CSV data with embedded filename - filename = newValue._filename; - } else if (typeof newValue === 'object' && newValue.data) { - // Wrapper objects (for strings that couldn't have properties added, or other wrapper types) - filename = newValue.filename || filename; - actualData = newValue.data; - } - } - - // Handle both string (CSV) and ArrayBuffer (Excel) data - if (actualData && ((typeof actualData === 'string' && actualData.length > 0) || (actualData instanceof ArrayBuffer && actualData.byteLength > 0))) { + if (newValue && newValue.data && newValue.filename) { try { - // Check if this is an Excel file using the extracted filename - if (filename && $scope.data_object.isExcelFile(filename)) { - // Store the original filename for later use in worksheet switching - $scope.originalFilename = filename; - // Parse as Excel file using the actual data + // Store filename for later use in worksheet switching + $scope.currentFilename = newValue.filename; + + // Process file based on type + if ($scope.data_object.isExcelFile(newValue.filename)) { + // Parse as Excel file $scope.data_object.parseExcel( - actualData, - filename, - $scope.file.chosenEncoding, - $scope.file.startAtRow, - $scope.profile.extraRow, + newValue.data, + newValue.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, 0 // default to first worksheet ); + + // Initialize worksheet selection for multi-sheet Excel files + if ($scope.data_object.worksheetNames && $scope.data_object.worksheetNames.length > 0) { + $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) + $scope.$evalAsync(); + } } else { - // Parse as CSV file (existing logic) using actualData + // Parse as CSV file if ($scope.file.chosenDelimiter == "auto") { - $scope.data_object.parseCsv(actualData, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); + $scope.data_object.parseCsv(newValue.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); } else { - $scope.data_object.parseCsv(actualData, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + $scope.data_object.parseCsv(newValue.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); } } - // Initialize worksheet selection for Excel files - if ($scope.filename && $scope.isExcelFile($scope.filename) && $scope.data_object.worksheetNames && $scope.data_object.worksheetNames.length > 0) { - $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) - // Force Angular to update the binding - $scope.$evalAsync(); - } - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); } catch (error) { console.error('Error parsing file:', error); @@ -430,26 +330,15 @@ angular.element(document).ready(function () { // Handle worksheet selection for Excel files $scope.worksheetChosen = function(worksheetIndex) { - if (($scope.filename || $scope.originalFilename) && $scope.data.source && $scope.isExcelFile($scope.filename || $scope.originalFilename)) { + if ($scope.currentFilename && $scope.data.source && $scope.isExcelFile($scope.currentFilename)) { try { - var actualData = $scope.data.source; - - // Extract actual data from wrapper objects (same logic as in $watch) - if ($scope.data.source.isArrayBuffer && $scope.data.source.data) { - actualData = $scope.data.source.data; - } else if ($scope.data.source.isString && $scope.data.source.data) { - actualData = $scope.data.source.data; - } else if (typeof $scope.data.source === 'object' && $scope.data.source.data) { - actualData = $scope.data.source.data; - } - // Convert to number if it's a string (from ng-value) var index = typeof worksheetIndex === 'string' ? parseInt(worksheetIndex, 10) : worksheetIndex; // Re-parse the Excel file with the selected worksheet $scope.data_object.parseExcel( - actualData, - $scope.filename || $scope.originalFilename, + $scope.data.source.data, + $scope.currentFilename, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, @@ -458,7 +347,6 @@ angular.element(document).ready(function () { ); $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - // Force Angular to update the view $scope.$evalAsync(); } catch (error) { console.error('Error switching worksheet:', error); diff --git a/tests/app.test.js b/tests/app.test.js index aadef47..07e7b8a 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -184,12 +184,15 @@ describe('ParseController', () => { $scope.file.chosenDelimiter = 'auto'; $scope.file.startAtRow = 1; $scope.profile.extraRow = false; - const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; + const csvData = { + data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', + filename: 'test.csv' + }; watchCallbacks['data.source'](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( - csvData, + csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow @@ -221,12 +224,15 @@ describe('ParseController', () => { $scope.file.chosenDelimiter = ';'; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; - const csvData = 'Date;Payee;Amount\n2024-01-01;Store;-50.00'; + const csvData = { + data: 'Date;Payee;Amount\n2024-01-01;Store;-50.00', + filename: 'test.csv' + }; watchCallbacks['data.source'](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( - csvData, + csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, @@ -404,12 +410,19 @@ describe('ParseController', () => { describe('Worksheet Selection', () => { beforeEach(() => { // Set up Excel file scenario - $scope.filename = 'test.xlsx'; - $scope.data = { source: 'excel_binary_data' }; + $scope.currentFilename = 'test.xlsx'; + $scope.data = { + source: { + data: 'excel_binary_data', + filename: 'test.xlsx' + } + }; $scope.data_object.parseExcel = jest.fn(); $scope.data_object.converted_json = jest.fn(() => [ { Date: '2024-01-01', Payee: 'Excel Store', Amount: '-75.00' } ]); + $scope.$evalAsync = jest.fn(); + global.alert = jest.fn(); }); test('worksheetChosen should re-parse Excel with new worksheet index', () => { @@ -473,7 +486,7 @@ describe('ParseController', () => { }); test('worksheetChosen should do nothing if filename is not set', () => { - $scope.filename = null; + $scope.currentFilename = null; $scope.worksheetChosen(1); @@ -491,7 +504,7 @@ describe('ParseController', () => { }); test('worksheetChosen should do nothing for non-Excel files', () => { - $scope.filename = 'test.csv'; + $scope.currentFilename = 'test.csv'; $scope.worksheetChosen(1); @@ -527,19 +540,21 @@ describe('ParseController', () => { controllerFn($scope, $location); // Set up Excel file scenario - $scope.filename = 'test.xlsx'; $scope.data_object.isExcelFile.mockReturnValue(true); $scope.file.chosenDelimiter = 'auto'; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; - const excelData = 'excel_binary_data'; + const excelData = { + data: 'excel_binary_data', + filename: 'test.xlsx' + }; // Simulate Excel file data change watchCallbacks['data.source'](excelData, null); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( - excelData, + excelData.data, 'test.xlsx', $scope.file.chosenEncoding, $scope.file.startAtRow, @@ -575,16 +590,18 @@ describe('ParseController', () => { controllerFn($scope, $location); // Set up Excel file scenario with custom delimiter - $scope.filename = 'test.xlsx'; $scope.data_object.isExcelFile.mockReturnValue(true); $scope.file.chosenDelimiter = ';'; - const excelData = 'excel_binary_data'; + const excelData = { + data: 'excel_binary_data', + filename: 'test.xlsx' + }; watchCallbacks['data.source'](excelData, null); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( - excelData, + excelData.data, 'test.xlsx', $scope.file.chosenEncoding, $scope.file.startAtRow, @@ -610,16 +627,18 @@ describe('ParseController', () => { controllerFn($scope, $location); // Set up CSV file scenario - $scope.filename = 'test.csv'; $scope.data_object.isExcelFile.mockReturnValue(false); $scope.file.chosenDelimiter = 'auto'; - const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; + const csvData = { + data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', + filename: 'test.csv' + }; watchCallbacks['data.source'](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( - csvData, + csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow @@ -648,7 +667,6 @@ describe('ParseController', () => { controllerFn($scope, $location); // Set up Excel file scenario - $scope.filename = 'test.xlsx'; $scope.data_object.isExcelFile.mockReturnValue(true); // Make parseExcel throw an error @@ -656,7 +674,10 @@ describe('ParseController', () => { throw new Error('Corrupted Excel file'); }); - const excelData = 'corrupted_excel_data'; + const excelData = { + data: 'corrupted_excel_data', + filename: 'test.xlsx' + }; // This should not crash the watcher expect(() => { @@ -739,15 +760,17 @@ describe('ParseController', () => { controllerFn($scope, $location); // Set up CSV file scenario - $scope.filename = 'test.csv'; $scope.data_object.isExcelFile.mockReturnValue(false); - const csvData = 'Date,Payee,Amount\n2024-01-01,Store,-50.00'; + const csvData = { + data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', + filename: 'test.csv' + }; watchCallbacks['data.source'](csvData, null); - // Should not set selectedWorksheet for CSV files - expect($scope.file.selectedWorksheet).toBeUndefined(); + // Should not set selectedWorksheet for CSV files (it starts as 0 from setInitialScopeState) + expect($scope.file.selectedWorksheet).toBe(0); }); }); }); \ No newline at end of file diff --git a/tests/directives.test.js b/tests/directives.test.js index 2254ed8..d3463f3 100644 --- a/tests/directives.test.js +++ b/tests/directives.test.js @@ -127,8 +127,7 @@ describe('AngularJS Directives', () => { // For CSV files, the data is wrapped in an object with filename expect(mockScope.fileread).toEqual({ data: 'csv,data\ntest,value', - filename: 'test.csv', - isString: true + filename: 'test.csv' }); }); }); From 3d6113320d48e381d852353adf64da55364c9775 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 10:09:45 +0200 Subject: [PATCH 17/35] Extract shared file processing utilities into FileUtils module - Create new src/file_utils.js module with shared file processing functions - Extract file extension detection, Excel file identification, and reading method logic - Update src/app.js and src/data_object.js to use FileUtils instead of duplicated code - Add comprehensive test suite for FileUtils module with 17 tests covering all functions - Support both browser and Node.js environments for cross-platform compatibility - Eliminate ~15-20 lines of duplicate code across different files - Maintain backward compatibility with existing functionality --- index.html | 9 ++- jest.setup.js | 5 +- src/app.js | 33 +++------ src/data_object.js | 39 +++++----- src/file_utils.js | 60 +++++++++++++++ tests/file_utils.test.js | 153 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 49 deletions(-) create mode 100644 src/file_utils.js create mode 100644 tests/file_utils.test.js diff --git a/index.html b/index.html index 9a51434..3e78ed2 100644 --- a/index.html +++ b/index.html @@ -29,6 +29,7 @@ + @@ -38,7 +39,7 @@ window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); - + gtag('config', 'G-ZNWHWW4QYB'); @@ -126,7 +127,7 @@ class="form-control" > -
+
-
+
@@ -267,3 +267,4 @@

YNAB Data (first 10 rows) + diff --git a/src/app.js b/src/app.js index 4582444..23d913a 100644 --- a/src/app.js +++ b/src/app.js @@ -306,18 +306,10 @@ angular.element(document).ready(function () { $scope.inverted_outflow = !$scope.inverted_outflow; } - // Helper methods for file type detection and display - $scope.isExcelFile = function(filename) { - return FileUtils.isExcelFile(filename); - }; - - $scope.getFileType = function(filename) { - return FileUtils.getFileType(filename); - }; // Handle worksheet selection for Excel files $scope.worksheetChosen = function(worksheetIndex) { - if ($scope.currentFilename && $scope.data.source && $scope.isExcelFile($scope.currentFilename)) { + if ($scope.currentFilename && $scope.data.source && FileUtils.isExcelFile($scope.currentFilename)) { try { // Convert to number if it's a string (from ng-value) var index = typeof worksheetIndex === 'string' ? parseInt(worksheetIndex, 10) : worksheetIndex; @@ -356,3 +348,4 @@ angular.element(document).ready(function () { }); angular.bootstrap(document, ["app"]); }); + diff --git a/tests/app.test.js b/tests/app.test.js index 07e7b8a..aeae543 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -50,26 +50,26 @@ describe('ParseController', () => { // Clear all mocks jest.clearAllMocks(); jest.resetModules(); - + // Create mock $scope $scope = { $watch: jest.fn(), $apply: jest.fn((fn) => fn && fn()) }; - - // Create mock $location + + // Create mock $location $location = { search: jest.fn(() => ({})) }; - + // Load the app.js file to register the controller require('../src/app.js'); - + // Get the controller function that was registered const controllerCalls = mockModule.controller.mock.calls; const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); const controllerFn = parseControllerCall[1]; - + // Execute the controller controller = controllerFn($scope, $location); }); @@ -88,7 +88,7 @@ describe('ParseController', () => { // Initially only has default profile $scope.profiles = { 'default profile': {} }; expect($scope.nonDefaultProfilesExist()).toBe(false); - + // Add another profile $scope.profiles['custom-profile'] = {}; expect($scope.nonDefaultProfilesExist()).toBe(true); @@ -97,12 +97,12 @@ describe('ParseController', () => { test('should toggle between old and new column formats', () => { // Start with old format expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); - + // Toggle to new format $scope.toggleColumnFormat(); expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Amount']); expect($scope.profile.columnFormat).toEqual(['Date', 'Payee', 'Memo', 'Amount']); - + // Toggle back to old format $scope.toggleColumnFormat(); expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); @@ -130,7 +130,7 @@ describe('ParseController', () => { test('should switch profiles and call URL search', () => { $scope.profileChosen('new-profile'); - + expect($location.search).toHaveBeenCalledWith('profile', 'new-profile'); // Note: The function uses $scope.profileName instead of the parameter, // so it will use the existing profile, not the new one @@ -139,10 +139,10 @@ describe('ParseController', () => { test('should invert flows when toggle is called', () => { expect($scope.inverted_outflow).toBe(false); - + $scope.invert_flows(); expect($scope.inverted_outflow).toBe(true); - + $scope.invert_flows(); expect($scope.inverted_outflow).toBe(false); }); @@ -150,7 +150,7 @@ describe('ParseController', () => { test('should reset app state when reloadApp is called', () => { $scope.setInitialScopeState = jest.fn(); $scope.reloadApp(); - + expect($scope.setInitialScopeState).toHaveBeenCalled(); }); }); @@ -171,7 +171,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -179,7 +179,7 @@ describe('ParseController', () => { const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate file data change with auto delimiter $scope.file.chosenDelimiter = 'auto'; $scope.file.startAtRow = 1; @@ -188,9 +188,9 @@ describe('ParseController', () => { data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', filename: 'test.csv' }; - + watchCallbacks['data.source'](csvData, null); - + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, @@ -211,7 +211,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -219,7 +219,7 @@ describe('ParseController', () => { const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate file data change with custom delimiter $scope.file.chosenDelimiter = ';'; $scope.file.startAtRow = 2; @@ -228,9 +228,9 @@ describe('ParseController', () => { data: 'Date;Payee;Amount\n2024-01-01;Store;-50.00', filename: 'test.csv' }; - + watchCallbacks['data.source'](csvData, null); - + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, @@ -246,7 +246,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -254,14 +254,14 @@ describe('ParseController', () => { const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Set initial inverted_outflow to false $scope.inverted_outflow = false; - + // Simulate inverted outflow change - the watch callback should update preview $scope.inverted_outflow = true; watchCallbacks['inverted_outflow'](true, false); - + expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, @@ -276,7 +276,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback, deep) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -284,11 +284,11 @@ describe('ParseController', () => { const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); - + // Simulate column mapping change const newMapping = { Date: 'Transaction Date', Payee: 'Merchant' }; watchCallbacks['ynab_map'](newMapping, {}); - + expect($scope.profile.chosenColumns).toEqual(newMapping); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, @@ -300,9 +300,9 @@ describe('ParseController', () => { test('should generate CSV string for download', () => { $scope.data_object.converted_csv.mockReturnValue('Date,Payee,Amount\n2024-01-01,Store,-50.00'); - + const result = $scope.csvString(); - + expect($scope.data_object.converted_csv).toHaveBeenCalledWith( null, $scope.ynab_cols, @@ -319,93 +319,43 @@ describe('ParseController', () => { download: '', click: jest.fn() }; - + // Mock Date constructor to return a specific date const mockDate = new Date('2024-01-01'); mockDate.yyyymmdd = jest.fn(() => '20240101'); jest.spyOn(global, 'Date').mockImplementation(() => mockDate); - + // Mock document.createElement to return our mock anchor global.document.createElement = jest.fn(() => mockAnchor); global.document.body.appendChild = jest.fn(); - + // Use realistic CSV data with special characters to test encoding const csvData = 'Date,Payee,Memo,Amount\n2024-01-01,"Store ""ABC""","Café & Groceries","-$50.00"'; $scope.data_object.converted_csv.mockReturnValue(csvData); - + $scope.downloadFile(); - + // Verify DOM interactions expect(global.document.createElement).toHaveBeenCalledWith('a'); expect(mockAnchor.target).toBe('_blank'); expect(mockAnchor.download).toBe('ynab_data_20240101.csv'); expect(global.document.body.appendChild).toHaveBeenCalledWith(mockAnchor); expect(mockAnchor.click).toHaveBeenCalled(); - + // Verify the actual encoding pipeline: btoa(unescape(encodeURIComponent(csvData))) const expectedHref = 'data:attachment/csv;base64,' + btoa(unescape(encodeURIComponent(csvData))); expect(mockAnchor.href).toBe(expectedHref); - + // Verify we can decode it back to the original CSV data const base64Part = mockAnchor.href.split('data:attachment/csv;base64,')[1]; const decodedData = decodeURIComponent(escape(atob(base64Part))); expect(decodedData).toBe(csvData); - + // Restore Date mock global.Date.mockRestore(); }); }); - describe('Excel Helper Functions', () => { - test('isExcelFile should detect Excel files correctly', () => { - expect($scope.isExcelFile('test.xlsx')).toBe(true); - expect($scope.isExcelFile('test.xls')).toBe(true); - expect($scope.isExcelFile('test.xlsm')).toBe(true); - expect($scope.isExcelFile('test.xlsb')).toBe(true); - expect($scope.isExcelFile('TEST.XLSX')).toBe(true); // case insensitive - expect($scope.isExcelFile('document.xlsx')).toBe(true); - }); - - test('isExcelFile should reject non-Excel files', () => { - expect($scope.isExcelFile('test.csv')).toBe(false); - expect($scope.isExcelFile('test.txt')).toBe(false); - expect($scope.isExcelFile('test.pdf')).toBe(false); - expect($scope.isExcelFile('document.doc')).toBe(false); - expect($scope.isExcelFile('file.json')).toBe(false); - }); - - test('isExcelFile should handle edge cases', () => { - expect($scope.isExcelFile('')).toBe(false); - expect($scope.isExcelFile(null)).toBe(false); - expect($scope.isExcelFile(undefined)).toBe(false); - expect($scope.isExcelFile('file')).toBe(false); // no extension - expect($scope.isExcelFile('file.')).toBe(false); // empty extension - expect($scope.isExcelFile('.xlsx')).toBe(true); // hidden file - }); - - test('getFileType should return correct format strings for Excel files', () => { - expect($scope.getFileType('test.xlsx')).toBe('XLSX'); - expect($scope.getFileType('test.xls')).toBe('XLS'); - expect($scope.getFileType('test.xlsm')).toBe('XLSM'); - expect($scope.getFileType('test.xlsb')).toBe('XLSB'); - expect($scope.getFileType('TEST.XLSX')).toBe('XLSX'); // case insensitive - }); - - test('getFileType should return CSV for non-Excel files', () => { - expect($scope.getFileType('test.csv')).toBe('CSV'); - expect($scope.getFileType('test.txt')).toBe('CSV'); - expect($scope.getFileType('test.pdf')).toBe('CSV'); - expect($scope.getFileType('document.doc')).toBe('CSV'); - }); - - test('getFileType should handle edge cases', () => { - expect($scope.getFileType('')).toBe(''); - expect($scope.getFileType(null)).toBe(''); - expect($scope.getFileType(undefined)).toBe(''); - expect($scope.getFileType('file')).toBe('CSV'); // no extension - expect($scope.getFileType('file.')).toBe('CSV'); // empty extension - }); - }); describe('Worksheet Selection', () => { beforeEach(() => { @@ -466,7 +416,7 @@ describe('ParseController', () => { // Mock console.error and alert to avoid noise in tests global.console.error = jest.fn(); global.alert = jest.fn(); - + // Make parseExcel throw an error $scope.data_object.parseExcel.mockImplementation(() => { throw new Error('Invalid worksheet'); @@ -479,7 +429,7 @@ describe('ParseController', () => { expect(global.console.error).toHaveBeenCalledWith('Error switching worksheet:', expect.any(Error)); expect(global.alert).toHaveBeenCalledWith('Error switching worksheet: Invalid worksheet'); - + // Clean up mocks delete global.console.error; delete global.alert; @@ -530,8 +480,8 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - - // Re-initialize to capture watch callbacks + + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); const controllerCalls = mockModule.controller.mock.calls; @@ -544,15 +494,15 @@ describe('ParseController', () => { $scope.file.chosenDelimiter = 'auto'; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; - + const excelData = { data: 'excel_binary_data', filename: 'test.xlsx' }; - + // Simulate Excel file data change watchCallbacks['data.source'](excelData, null); - + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( excelData.data, 'test.xlsx', @@ -562,7 +512,7 @@ describe('ParseController', () => { null, // auto delimiter becomes null 0 // default worksheet index ); - + expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, @@ -580,7 +530,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -592,14 +542,14 @@ describe('ParseController', () => { // Set up Excel file scenario with custom delimiter $scope.data_object.isExcelFile.mockReturnValue(true); $scope.file.chosenDelimiter = ';'; - + const excelData = { data: 'excel_binary_data', filename: 'test.xlsx' }; - + watchCallbacks['data.source'](excelData, null); - + expect($scope.data_object.parseExcel).toHaveBeenCalledWith( excelData.data, 'test.xlsx', @@ -617,7 +567,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -629,21 +579,21 @@ describe('ParseController', () => { // Set up CSV file scenario $scope.data_object.isExcelFile.mockReturnValue(false); $scope.file.chosenDelimiter = 'auto'; - + const csvData = { data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', filename: 'test.csv' }; - + watchCallbacks['data.source'](csvData, null); - + expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow ); - + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); }); @@ -657,7 +607,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -668,25 +618,25 @@ describe('ParseController', () => { // Set up Excel file scenario $scope.data_object.isExcelFile.mockReturnValue(true); - + // Make parseExcel throw an error $scope.data_object.parseExcel.mockImplementation(() => { throw new Error('Corrupted Excel file'); }); - + const excelData = { data: 'corrupted_excel_data', filename: 'test.xlsx' }; - + // This should not crash the watcher expect(() => { watchCallbacks['data.source'](excelData, null); }).not.toThrow(); - + expect(global.console.error).toHaveBeenCalledWith('Error parsing file:', expect.any(Error)); expect(global.alert).toHaveBeenCalledWith('Error parsing file: Corrupted Excel file'); - + // Clean up mocks delete global.console.error; delete global.alert; @@ -698,7 +648,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -711,7 +661,7 @@ describe('ParseController', () => { watchCallbacks['data.source']('', null); watchCallbacks['data.source'](null, null); watchCallbacks['data.source'](undefined, null); - + expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); expect($scope.data_object.parseCsv).not.toHaveBeenCalled(); }); @@ -722,7 +672,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -735,11 +685,11 @@ describe('ParseController', () => { $scope.filename = 'test.xlsx'; $scope.data_object.isExcelFile.mockReturnValue(true); $scope.data_object.worksheetNames = ['Sheet1', 'Data', 'Summary']; - + const excelData = 'excel_binary_data'; - + watchCallbacks['data.source'](excelData, null); - + // Should initialize selectedWorksheet to 0 for multi-sheet Excel files expect($scope.file.selectedWorksheet).toBe(0); }); @@ -750,7 +700,7 @@ describe('ParseController', () => { $scope.$watch.mockImplementation((expr, callback) => { watchCallbacks[expr] = callback; }); - + // Re-initialize to capture watch callbacks jest.resetModules(); require('../src/app.js'); @@ -761,16 +711,17 @@ describe('ParseController', () => { // Set up CSV file scenario $scope.data_object.isExcelFile.mockReturnValue(false); - + const csvData = { data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', filename: 'test.csv' }; - + watchCallbacks['data.source'](csvData, null); - + // Should not set selectedWorksheet for CSV files (it starts as 0 from setInitialScopeState) expect($scope.file.selectedWorksheet).toBe(0); }); }); -}); \ No newline at end of file +}); + From 97d7d04a264682c397d20ea4813a8ae880d728cc Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 10:53:23 +0200 Subject: [PATCH 19/35] Consolidate file utilities and remove redundant functions - Remove redundant isExcelFileHelper() wrapper function from app.js - Replace isExcelFileHelper() calls with direct FileUtils.isExcelFile() usage - Move createFileDataWrapper() function to FileUtils as createDataWrapper() - Update file processing to use FileUtils.createDataWrapper() - Add comprehensive tests for createDataWrapper() function (3 new tests) - Eliminates 12 lines of duplicate/redundant code - Better centralization of file utility functions - Increases test coverage from 97 to 100 tests - Maintains identical functionality while improving code organization --- src/app.js | 18 +++--------------- src/file_utils.js | 11 ++++++++++- tests/file_utils.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/app.js b/src/app.js index 23d913a..c57960f 100644 --- a/src/app.js +++ b/src/app.js @@ -45,27 +45,14 @@ Date.prototype.yyyymmdd = function () { angular.element(document).ready(function () { // Shared helper functions for file processing - function createFileDataWrapper(content, filename) { - return { - data: content, - filename: filename - }; - } - function isExcelFileHelper(filename) { - if (!filename) return false; - if (window.DataObject) { - return new window.DataObject().isExcelFile(filename); - } - return FileUtils.isExcelFile(filename); - } function processFile(file, scope, targetProperty, attributes) { var reader = new FileReader(); reader.onload = function(loadEvent) { scope.$apply(function() { - var fileData = createFileDataWrapper(loadEvent.target.result, file.name); + var fileData = FileUtils.createDataWrapper(loadEvent.target.result, file.name); scope.filename = file.name; scope[targetProperty] = fileData; }); @@ -74,7 +61,7 @@ angular.element(document).ready(function () { reader.onerror = function(error) { console.error('FileReader error:', error); }; - if (isExcelFileHelper(file.name)) { + if (FileUtils.isExcelFile(file.name)) { var method = FileUtils.getExcelReadingMethod(file.name); if (method === 'arrayBuffer') { reader.readAsArrayBuffer(file); @@ -349,3 +336,4 @@ angular.element(document).ready(function () { angular.bootstrap(document, ["app"]); }); + diff --git a/src/file_utils.js b/src/file_utils.js index d6aea3c..22df580 100644 --- a/src/file_utils.js +++ b/src/file_utils.js @@ -38,13 +38,21 @@ }; } + function createDataWrapper(content, filename) { + return { + data: content, + filename: filename + }; + } + // Public API var FileUtils = { getFileExtension: getFileExtension, isExcelFile: isExcelFile, getExcelReadingMethod: getExcelReadingMethod, getFileType: getFileType, - constants: getFileTypeConstants + constants: getFileTypeConstants, + createDataWrapper: createDataWrapper }; // Support both browser and Node.js environments @@ -58,3 +66,4 @@ global.FileUtils = FileUtils; } })(); + diff --git a/tests/file_utils.test.js b/tests/file_utils.test.js index 73c1b03..c70cf01 100644 --- a/tests/file_utils.test.js +++ b/tests/file_utils.test.js @@ -114,6 +114,43 @@ describe('FileUtils', () => { }); }); + describe('createDataWrapper', () => { + test('should create data wrapper with content and filename', () => { + const content = 'test data content'; + const filename = 'test.csv'; + + const wrapper = FileUtils.createDataWrapper(content, filename); + + expect(wrapper).toEqual({ + data: 'test data content', + filename: 'test.csv' + }); + }); + + test('should handle different content types', () => { + // Test with different data types + const binaryData = new ArrayBuffer(8); + const wrapper1 = FileUtils.createDataWrapper(binaryData, 'test.xlsx'); + expect(wrapper1.data).toBe(binaryData); + expect(wrapper1.filename).toBe('test.xlsx'); + + const textData = 'csv,data,here'; + const wrapper2 = FileUtils.createDataWrapper(textData, 'data.csv'); + expect(wrapper2.data).toBe(textData); + expect(wrapper2.filename).toBe('data.csv'); + }); + + test('should handle empty or null values', () => { + const wrapper1 = FileUtils.createDataWrapper('', ''); + expect(wrapper1.data).toBe(''); + expect(wrapper1.filename).toBe(''); + + const wrapper2 = FileUtils.createDataWrapper(null, null); + expect(wrapper2.data).toBe(null); + expect(wrapper2.filename).toBe(null); + }); + }); + describe('constants', () => { test('should provide file extension constants', () => { const constants = FileUtils.constants(); @@ -151,3 +188,4 @@ describe('FileUtils', () => { }); }); }); + From 5ce11ebd4196a2ff4f2f6e98fa3f8cdc1e33434c Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 11:10:54 +0200 Subject: [PATCH 20/35] Setup Prettier and lint-staged with Husky --- .husky/pre-commit | 1 + .prettierignore | 9 + package-lock.json | 655 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 14 +- 4 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 .husky/pre-commit create mode 100644 .prettierignore diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..c27d889 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e693c27 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +node_modules +coverage +*.log +.vscode +dist +build +tmp +*.min.js +*.min.css \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6a0e054..0b1cc25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,12 @@ "@babel/preset-env": "^7.28.0", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", + "husky": "^9.1.7", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", - "jsdom": "^26.1.0" + "jsdom": "^26.1.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2" } }, "node_modules/@ampproject/remapping": { @@ -3658,6 +3661,93 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3709,6 +3799,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4128,6 +4225,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4467,6 +4577,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4823,6 +4946,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -5962,6 +6101,19 @@ "node": ">=6" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -5975,6 +6127,192 @@ "dev": true, "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.1.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.1.2.tgz", + "integrity": "sha512-sQKw2Si2g9KUZNY3XNvRuDq4UJqpHwF0/FQzZR2M7I5MvtpWvibikCjUVJzZdGE0ByurEl3KQNvsGetd1ty1/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0", + "debug": "^4.4.1", + "lilconfig": "^3.1.3", + "listr2": "^8.3.3", + "micromatch": "^4.0.8", + "nano-spawn": "^1.0.2", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/lint-staged/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/lint-staged/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -6009,6 +6347,160 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6133,6 +6625,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -6179,6 +6684,19 @@ "dev": true, "license": "MIT" }, + "node_modules/nano-spawn": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.2.tgz", + "integrity": "sha512-21t+ozMQDAL/UGgQVBbZ/xXvNO10++ZPuTmKRO8k9V3AClVRht49ahtDjfY8l1q6nSHOrE5ASfthzH3ol6R/hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, "node_modules/napi-postinstall": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz", @@ -6524,6 +7042,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -6608,6 +7139,22 @@ "lodash": "^4.17.14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", @@ -6868,6 +7415,46 @@ "node": ">= 0.8.0" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -7280,6 +7867,49 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/socket.io": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", @@ -7508,6 +8138,16 @@ "node": ">= 0.10.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -8241,6 +8881,19 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 7e063a6..9fb6329 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "start": "http-server --port 3000 -o", - "bs": "browser-sync . -w" + "bs": "browser-sync . -w", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepare": "husky", + "postinstall": "husky" }, "repository": { "type": "git", @@ -31,8 +35,14 @@ "@babel/preset-env": "^7.28.0", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", + "husky": "^9.1.7", "jest": "^30.0.5", "jest-environment-jsdom": "^30.0.5", - "jsdom": "^26.1.0" + "jsdom": "^26.1.0", + "lint-staged": "^16.1.2", + "prettier": "^3.6.2" + }, + "lint-staged": { + "*.{js,json,md,html,css}": "prettier --write" } } From d9caa0d26eba467e9246f323a0950b368fedf073 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 11:16:40 +0200 Subject: [PATCH 21/35] Prettier fixes --- .babelrc | 2 +- .github/workflows/test.yml | 34 +- README.md | 12 +- app.css | 105 +++- docker-compose.yml | 4 +- index.html | 129 +++-- jest.config.js | 17 +- jest.setup.js | 2 +- src/app.js | 500 ++++++++++------- src/data_object.js | 112 ++-- src/file_utils.js | 33 +- tests/app.test.js | 418 ++++++++------ tests/data_object.test.js | 1074 ++++++++++++++++++++++-------------- tests/directives.test.js | 281 +++++----- tests/file_utils.test.js | 270 +++++---- 15 files changed, 1786 insertions(+), 1207 deletions(-) diff --git a/.babelrc b/.babelrc index 8aa924d..1320b9a 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,3 @@ { "presets": ["@babel/preset-env"] -} \ No newline at end of file +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 960e8f7..b5c97e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Run Tests on: push: - branches: [ main, gh-pages ] + branches: [main, gh-pages] pull_request: - branches: [ main, gh-pages ] + branches: [main, gh-pages] merge_group: jobs: @@ -12,22 +12,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: '22.x' - cache: 'npm' + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" - - name: Install dependencies - run: npm ci + - name: Install dependencies + run: npm ci - - name: Run tests with coverage - run: npm run test:coverage + - name: Run tests with coverage + run: npm run test:coverage - - name: Upload coverage reports - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + - name: Upload coverage reports + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/README.md b/README.md index 73794e1..4a2b664 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,9 @@ # ynab-csv - Tool for making your CSV and Excel files ready to import into YNAB. http://aniav.github.io/ynab-csv/ - ## How to Use 1. Visit the link above. @@ -43,7 +41,6 @@ To run the project locally using Docker Compose: Your data never leaves your computer. All the processing happens locally. - ## Reporting Issues If you have any other issues or suggestions, go to https://github.com/aniav/ynab-csv/issues and create an issue if one doesn't already exist. If the issue has to do with your csv file, please create a new gist (https://gist.github.com/) with the content of the CSV file and share the link in the issue. If you tweak the CSV file before sharing, just make sure whatever version you end up sharing still causes the problem you describe. @@ -52,10 +49,11 @@ If you have any other issues or suggestions, go to https://github.com/aniav/ynab 1. Fork and clone the project 2. `cd` into project -3. Run `npm install` # You will need to install node and npm if it is not already -4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" - * Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) +3. Run `npm install` # You will need to install node and npm if it is not already +4. Run `npm start` # when running in Windows, modify package.json and replace "open" with "start" + +- Optional: run `npm run bs` instead to use [Browsersync](https://browsersync.io/) + 5. Make your changes locally and test them to make sure they work 6. Commit those changes and push to your forked repository 7. Make a new pull request - diff --git a/app.css b/app.css index 7dc7860..4cbf72d 100644 --- a/app.css +++ b/app.css @@ -1,28 +1,95 @@ -html, body, .dropzone { height: 100%; } +html, +body, +.dropzone { + height: 100%; +} /* Loading helpers*/ -body:not(.angular_loaded) .show_on_load { display: none; } -.angular_loaded .hide_on_load { display: none; } -#angular_is_loading { position: fixed; top: 0; right: 0; bottom: 0; left: 0; background-color: rgba(0,0,0,.5); color: white; font-size: 70px; text-align: center; padding-top: 200px; box-shadow: 0 0 10px rgba(0,0,0,.5); transition: all 2s; -webkit-transition: all 2s; } -body.angular_loaded #angular_is_loading { display: none; top: 3000px; bottom: -3000px; } - +body:not(.angular_loaded) .show_on_load { + display: none; +} +.angular_loaded .hide_on_load { + display: none; +} +#angular_is_loading { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(0, 0, 0, 0.5); + color: white; + font-size: 70px; + text-align: center; + padding-top: 200px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + transition: all 2s; + -webkit-transition: all 2s; +} +body.angular_loaded #angular_is_loading { + display: none; + top: 3000px; + bottom: -3000px; +} /* Header */ -#header_nav { position: fixed; top: 0; left: 0; right: 0; padding: 10px; background-color: rgb(240, 240, 240); z-index: 1; box-shadow: 0 0 10px rgba(0,0,0,.5); } -#header_nav h1 { display: inline-block; font-size: 20px; margin: 0; vertical-align: middle; margin-right: 15px;} - +#header_nav { + position: fixed; + top: 0; + left: 0; + right: 0; + padding: 10px; + background-color: rgb(240, 240, 240); + z-index: 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); +} +#header_nav h1 { + display: inline-block; + font-size: 20px; + margin: 0; + vertical-align: middle; + margin-right: 15px; +} /* Dropzone */ -.dropzone { padding-top: 54px; } -.dropzone.dragging { background-color: #D2E4F8; } - -#upload_wrapper { text-align: center;} -#upload_wrapper #drop_text { font-size: 60px; padding-top: 150px; } -#upload_wrapper .fileUpload { position: relative; overflow: hidden; margin: 10px; } -#upload_wrapper .fileUpload input { position: absolute; top: 0; right: 0; margin: 0; padding: 0; font-size: 20px; cursor: pointer; opacity: 0; filter: alpha(opacity=0); } +.dropzone { + padding-top: 54px; +} +.dropzone.dragging { + background-color: #d2e4f8; +} +#upload_wrapper { + text-align: center; +} +#upload_wrapper #drop_text { + font-size: 60px; + padding-top: 150px; +} +#upload_wrapper .fileUpload { + position: relative; + overflow: hidden; + margin: 10px; +} +#upload_wrapper .fileUpload input { + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + font-size: 20px; + cursor: pointer; + opacity: 0; + filter: alpha(opacity=0); +} -#tool_wrapper { margin-bottom: 40px; } -#tool_wrapper .table-container { overflow: auto; } +#tool_wrapper { + margin-bottom: 40px; +} +#tool_wrapper .table-container { + overflow: auto; +} -.dropdown-form {width: 200px;} \ No newline at end of file +.dropdown-form { + width: 200px; +} diff --git a/docker-compose.yml b/docker-compose.yml index a291848..25e88bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ -version: '3' +version: "3" services: ynab-csv-app: build: . ports: - - ${PORT}:3000 \ No newline at end of file + - ${PORT}:3000 diff --git a/index.html b/index.html index ba4296e..1fedb0b 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -8,7 +8,7 @@ href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous" - > + /> - + - + - + - + -
-
+
+

Drop CSV or Excel file
@@ -87,7 +110,13 @@ />
-
@@ -113,8 +141,7 @@ ng-click="$event.stopPropagation()" ng-change="delimiterChosen(file.chosenDelimiter)" class="form-control" - > - + >
@@ -125,19 +152,20 @@ ng-model="file.startAtRow" ng-change="startRowSet(file.startAtRow)" class="form-control" - > - + />
-
+
+ -
@@ -147,31 +175,40 @@ ng-model="profileName" class="form-control" ng-click="$event.stopPropagation()" - ng-change="profileChosen(profileName)"> - + ng-change="profileChosen(profileName)" + > +
-
-
+
@@ -194,9 +231,13 @@ Toggle column format
-

YNAB Data (first 10 rows) +

+ YNAB Data (first 10 rows) - + {{FileUtils.getFileType(filename)}}

@@ -218,16 +259,14 @@

YNAB Data (first 10 rows) ng-model="ynab_map[col]" ng-options="f for f in data_object.fields()" > - - + +

- +
- {{row[col]}} - {{row[col]}}
@@ -237,7 +276,8 @@

YNAB Data (first 10 rows)
- Imported {{FileUtils.getFileType(filename)}} (first 10 rows) + Imported {{FileUtils.getFileType(filename)}} + (first 10 rows)
@@ -267,4 +307,3 @@

YNAB Data (first 10 rows) - diff --git a/jest.config.js b/jest.config.js index 25ad6a0..9d971bd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,10 @@ module.exports = { - testEnvironment: 'jsdom', + testEnvironment: "jsdom", transform: { - '^.+\\.js$': 'babel-jest', + "^.+\\.js$": "babel-jest", }, - moduleFileExtensions: ['js'], - testMatch: ['**/__tests__/**/*.js', '**/?(*.)+(spec|test).js'], - collectCoverageFrom: [ - 'src/**/*.js', - '!src/**/*.min.js', - ], - setupFilesAfterEnv: ['/jest.setup.js'], -}; \ No newline at end of file + moduleFileExtensions: ["js"], + testMatch: ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"], + collectCoverageFrom: ["src/**/*.js", "!src/**/*.min.js"], + setupFilesAfterEnv: ["/jest.setup.js"], +}; diff --git a/jest.setup.js b/jest.setup.js index 05634ae..29d2c2e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -15,4 +15,4 @@ global.FileReader = jest.fn(() => ({ })); // Load FileUtils module for all tests -require('./src/file_utils.js'); +require("./src/file_utils.js"); diff --git a/src/app.js b/src/app.js index c57960f..f36c7fc 100644 --- a/src/app.js +++ b/src/app.js @@ -1,21 +1,49 @@ // see http://stackoverflow.com/questions/2897619/using-html5-javascript-to-generate-and-save-a-file // see http://stackoverflow.com/questions/18662404/download-lengthy-data-as-a-csv-file var encodings = [ - "UTF-8", "IBM866", "ISO-8859-1", "ISO-8859-2", "ISO-8859-3", "ISO-8859-4", "ISO-8859-5", - "ISO-8859-6", "ISO-8859-7", "ISO-8859-8", "ISO-8859-8-I", "ISO-8859-10", - "ISO-8859-13", "ISO-8859-14", "ISO-8859-15", "ISO-8859-16", "KOI8-R", - "KOI8-U", "macintosh", "windows-874", "windows-1250", "windows-1251", - "windows-1252", "windows-1253", "windows-1254", "windows-1255", - "windows-1256", "windows-1257", "windows-1258", "x-mac-cyrillic", "GBK", - "gb18030", "Big5", "EUC-JP", "ISO-2022-JP", "Shift_JIS", "EUC-KR", - "replacement", "UTF-16BE", "UTF-16LE", "x-user-defined" -] -var delimiters = [ - "auto", - ",", - ";", - "|" -] + "UTF-8", + "IBM866", + "ISO-8859-1", + "ISO-8859-2", + "ISO-8859-3", + "ISO-8859-4", + "ISO-8859-5", + "ISO-8859-6", + "ISO-8859-7", + "ISO-8859-8", + "ISO-8859-8-I", + "ISO-8859-10", + "ISO-8859-13", + "ISO-8859-14", + "ISO-8859-15", + "ISO-8859-16", + "KOI8-R", + "KOI8-U", + "macintosh", + "windows-874", + "windows-1250", + "windows-1251", + "windows-1252", + "windows-1253", + "windows-1254", + "windows-1255", + "windows-1256", + "windows-1257", + "windows-1258", + "x-mac-cyrillic", + "GBK", + "gb18030", + "Big5", + "EUC-JP", + "ISO-2022-JP", + "Shift_JIS", + "EUC-KR", + "replacement", + "UTF-16BE", + "UTF-16LE", + "x-user-defined", +]; +var delimiters = ["auto", ",", ";", "|"]; var old_ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; var new_ynab_cols = ["Date", "Payee", "Memo", "Amount"]; var defaultProfile = { @@ -27,43 +55,46 @@ var defaultProfile = { chosenEncoding: "UTF-8", chosenDelimiter: "auto", startAtRow: 1, - extraRow: false + extraRow: false, }; var defaultProfiles = { - "default profile": defaultProfile + "default profile": defaultProfile, }; Date.prototype.yyyymmdd = function () { var mm = this.getMonth() + 1; // getMonth() is zero-based var dd = this.getDate(); - return [this.getFullYear(), - (mm > 9 ? '' : '0') + mm, - (dd > 9 ? '' : '0') + dd - ].join(''); + return [ + this.getFullYear(), + (mm > 9 ? "" : "0") + mm, + (dd > 9 ? "" : "0") + dd, + ].join(""); }; angular.element(document).ready(function () { // Shared helper functions for file processing - function processFile(file, scope, targetProperty, attributes) { var reader = new FileReader(); - reader.onload = function(loadEvent) { - scope.$apply(function() { - var fileData = FileUtils.createDataWrapper(loadEvent.target.result, file.name); + reader.onload = function (loadEvent) { + scope.$apply(function () { + var fileData = FileUtils.createDataWrapper( + loadEvent.target.result, + file.name, + ); scope.filename = file.name; scope[targetProperty] = fileData; }); }; - reader.onerror = function(error) { - console.error('FileReader error:', error); + reader.onerror = function (error) { + console.error("FileReader error:", error); }; if (FileUtils.isExcelFile(file.name)) { var method = FileUtils.getExcelReadingMethod(file.name); - if (method === 'arrayBuffer') { + if (method === "arrayBuffer") { reader.readAsArrayBuffer(file); } else { reader.readAsBinaryString(file); @@ -79,7 +110,7 @@ angular.element(document).ready(function () { return { scope: { fileread: "=", - filename: "=" + filename: "=", }, link: function (scope, element, attributes) { try { @@ -87,14 +118,14 @@ angular.element(document).ready(function () { var file = changeEvent.target.files[0]; if (!file) return; - processFile(file, scope, 'fileread', attributes); + processFile(file, scope, "fileread", attributes); }); } catch (error) { - console.error('Error in fileread directive:', error); + console.error("Error in fileread directive:", error); } - } + }, }; - } + }, ]); angular.module("app").directive("dropzone", [ function () { @@ -104,7 +135,7 @@ angular.element(document).ready(function () { template: '
', scope: { dropzone: "=", - filename: "=" + filename: "=", }, link: function (scope, element, attributes) { element.bind("dragenter", function (event) { @@ -117,7 +148,8 @@ angular.element(document).ready(function () { event.preventDefault(); event.stopPropagation(); var dataTransfer; - dataTransfer = (event.dataTransfer || event.originalEvent.dataTransfer) + dataTransfer = + event.dataTransfer || event.originalEvent.dataTransfer; efct = dataTransfer.effectAllowed; dataTransfer.dropEffect = "move" === efct || "linkMove" === efct ? "move" : "copy"; @@ -131,209 +163,267 @@ angular.element(document).ready(function () { event.preventDefault(); event.stopPropagation(); - var file = (event.dataTransfer || event.originalEvent.dataTransfer).files[0]; + var file = (event.dataTransfer || event.originalEvent.dataTransfer) + .files[0]; if (!file) return; - processFile(file, scope, 'dropzone', attributes); + processFile(file, scope, "dropzone", attributes); }); element.bind("paste", function (event) { - var items = (event.clipboardData || event.originalEvent.clipboardData).items; + var items = ( + event.clipboardData || event.originalEvent.clipboardData + ).items; var data; for (var i = 0; i < items.length; i++) { - if (items[i].type == 'text/plain') { + if (items[i].type == "text/plain") { data = items[i]; break; } } if (!data) return; - data.getAsString(function(text) { + data.getAsString(function (text) { scope.$apply(function () { scope.dropzone = text; }); }); }); - } + }, }; - } + }, ]); // Application code - angular.module("app") - .config(function($locationProvider) { - $locationProvider.html5Mode({ - enabled: true, - requireBase: false, - }).hashPrefix('!'); - }) - .controller("ParseController", function ($scope, $location) { - $scope.angular_loaded = true; + angular + .module("app") + .config(function ($locationProvider) { + $locationProvider + .html5Mode({ + enabled: true, + requireBase: false, + }) + .hashPrefix("!"); + }) + .controller("ParseController", function ($scope, $location) { + $scope.angular_loaded = true; - $scope.setInitialScopeState = function () { - $scope.profileName = ($location.search().profile || localStorage.getItem('profileName') || 'default profile').toLowerCase(); - $scope.profiles = JSON.parse(localStorage.getItem('profiles')) || defaultProfiles; - if(!$scope.profiles[$scope.profileName]) { - $scope.profiles[$scope.profileName] = defaultProfile; - } - $scope.profile = $scope.profiles[$scope.profileName]; - $scope.ynab_cols = $scope.profile.columnFormat; - $scope.data = {}; - $scope.ynab_map = $scope.profile.chosenColumns - $scope.inverted_outflow = false; - $scope.file = { - encodings: encodings, - delimiters: delimiters, - chosenEncoding: $scope.profile.chosenEncoding || "UTF-8", - chosenDelimiter: $scope.profile.chosenDelimiter || "auto", - startAtRow: $scope.profile.startAtRow, - extraRow: $scope.profile.extraRow || false, - selectedWorksheet: 0 // Use index as source of truth + $scope.setInitialScopeState = function () { + $scope.profileName = ( + $location.search().profile || + localStorage.getItem("profileName") || + "default profile" + ).toLowerCase(); + $scope.profiles = + JSON.parse(localStorage.getItem("profiles")) || defaultProfiles; + if (!$scope.profiles[$scope.profileName]) { + $scope.profiles[$scope.profileName] = defaultProfile; + } + $scope.profile = $scope.profiles[$scope.profileName]; + $scope.ynab_cols = $scope.profile.columnFormat; + $scope.data = {}; + $scope.ynab_map = $scope.profile.chosenColumns; + $scope.inverted_outflow = false; + $scope.file = { + encodings: encodings, + delimiters: delimiters, + chosenEncoding: $scope.profile.chosenEncoding || "UTF-8", + chosenDelimiter: $scope.profile.chosenDelimiter || "auto", + startAtRow: $scope.profile.startAtRow, + extraRow: $scope.profile.extraRow || false, + selectedWorksheet: 0, // Use index as source of truth + }; + $scope.data_object = new DataObject(); + $scope.filename = null; }; - $scope.data_object = new DataObject(); - $scope.filename = null; - } - $scope.setInitialScopeState(); - $scope.profileChosen = function (profileName) { - $location.search('profile', profileName); - $scope.profile = $scope.profiles[$scope.profileName]; - $scope.ynab_cols = $scope.profile.columnFormat; - $scope.ynab_map = $scope.profile.chosenColumns; - localStorage.setItem('profileName', profileName); - }; - $scope.encodingChosen = function (encoding) { - $scope.profile.chosenEncoding = encoding; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.delimiterChosen = function (delimiter) { - $scope.profile.chosenDelimiter = delimiter; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.startRowSet = function (startAtRow) { - $scope.profile.startAtRow = startAtRow; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.extraRowSet = function (extraRow) { - $scope.profile.extraRow = extraRow; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; - $scope.nonDefaultProfilesExist = function() { - return Object.keys($scope.profiles).length > 1; - }; - $scope.toggleColumnFormat = function () { - if ($scope.ynab_cols == new_ynab_cols) { - $scope.ynab_cols = old_ynab_cols; - } else { - $scope.ynab_cols = new_ynab_cols; - } - $scope.profile.columnFormat = $scope.ynab_cols - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - }; + $scope.setInitialScopeState(); + $scope.profileChosen = function (profileName) { + $location.search("profile", profileName); + $scope.profile = $scope.profiles[$scope.profileName]; + $scope.ynab_cols = $scope.profile.columnFormat; + $scope.ynab_map = $scope.profile.chosenColumns; + localStorage.setItem("profileName", profileName); + }; + $scope.encodingChosen = function (encoding) { + $scope.profile.chosenEncoding = encoding; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.delimiterChosen = function (delimiter) { + $scope.profile.chosenDelimiter = delimiter; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.startRowSet = function (startAtRow) { + $scope.profile.startAtRow = startAtRow; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.extraRowSet = function (extraRow) { + $scope.profile.extraRow = extraRow; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; + $scope.nonDefaultProfilesExist = function () { + return Object.keys($scope.profiles).length > 1; + }; + $scope.toggleColumnFormat = function () { + if ($scope.ynab_cols == new_ynab_cols) { + $scope.ynab_cols = old_ynab_cols; + } else { + $scope.ynab_cols = new_ynab_cols; + } + $scope.profile.columnFormat = $scope.ynab_cols; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + }; - $scope.$watch("data.source", function (newValue, oldValue) { - if (newValue && newValue.data && newValue.filename) { - try { - // Store filename for later use in worksheet switching - $scope.currentFilename = newValue.filename; + $scope.$watch("data.source", function (newValue, oldValue) { + if (newValue && newValue.data && newValue.filename) { + try { + // Store filename for later use in worksheet switching + $scope.currentFilename = newValue.filename; - // Process file based on type - if ($scope.data_object.isExcelFile(newValue.filename)) { - // Parse as Excel file - $scope.data_object.parseExcel( - newValue.data, - newValue.filename, - $scope.file.chosenEncoding, - $scope.file.startAtRow, - $scope.profile.extraRow, - $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, - 0 // default to first worksheet - ); + // Process file based on type + if ($scope.data_object.isExcelFile(newValue.filename)) { + // Parse as Excel file + $scope.data_object.parseExcel( + newValue.data, + newValue.filename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" + ? null + : $scope.file.chosenDelimiter, + 0, // default to first worksheet + ); - // Initialize worksheet selection for multi-sheet Excel files - if ($scope.data_object.worksheetNames && $scope.data_object.worksheetNames.length > 0) { - $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) - $scope.$evalAsync(); - } - } else { - // Parse as CSV file - if ($scope.file.chosenDelimiter == "auto") { - $scope.data_object.parseCsv(newValue.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow); + // Initialize worksheet selection for multi-sheet Excel files + if ( + $scope.data_object.worksheetNames && + $scope.data_object.worksheetNames.length > 0 + ) { + $scope.file.selectedWorksheet = 0; // Default to first worksheet (index 0) + $scope.$evalAsync(); + } } else { - $scope.data_object.parseCsv(newValue.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, $scope.file.chosenDelimiter); + // Parse as CSV file + if ($scope.file.chosenDelimiter == "auto") { + $scope.data_object.parseCsv( + newValue.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + ); + } else { + $scope.data_object.parseCsv( + newValue.data, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter, + ); + } } - } - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - } catch (error) { - console.error('Error parsing file:', error); - alert('Error parsing file: ' + error.message); + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + ); + } catch (error) { + console.error("Error parsing file:", error); + alert("Error parsing file: " + error.message); + } } - } - }); - $scope.$watch("inverted_outflow", function (newValue, oldValue) { - if (newValue != oldValue) { - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - } - }); - $scope.$watch( - "ynab_map", - function (newValue, oldValue) { - $scope.profile.chosenColumns = newValue; - localStorage.setItem('profiles', JSON.stringify($scope.profiles)); - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, newValue, $scope.inverted_outflow); - }, - true - ); - $scope.csvString = function () { - return $scope.data_object.converted_csv(null, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - }; - $scope.reloadApp = function () { - $scope.setInitialScopeState(); - } - $scope.invert_flows = function () { - $scope.inverted_outflow = !$scope.inverted_outflow; - } - + }); + $scope.$watch("inverted_outflow", function (newValue, oldValue) { + if (newValue != oldValue) { + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + ); + } + }); + $scope.$watch( + "ynab_map", + function (newValue, oldValue) { + $scope.profile.chosenColumns = newValue; + localStorage.setItem("profiles", JSON.stringify($scope.profiles)); + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + newValue, + $scope.inverted_outflow, + ); + }, + true, + ); + $scope.csvString = function () { + return $scope.data_object.converted_csv( + null, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + ); + }; + $scope.reloadApp = function () { + $scope.setInitialScopeState(); + }; + $scope.invert_flows = function () { + $scope.inverted_outflow = !$scope.inverted_outflow; + }; - // Handle worksheet selection for Excel files - $scope.worksheetChosen = function(worksheetIndex) { - if ($scope.currentFilename && $scope.data.source && FileUtils.isExcelFile($scope.currentFilename)) { - try { - // Convert to number if it's a string (from ng-value) - var index = typeof worksheetIndex === 'string' ? parseInt(worksheetIndex, 10) : worksheetIndex; + // Handle worksheet selection for Excel files + $scope.worksheetChosen = function (worksheetIndex) { + if ( + $scope.currentFilename && + $scope.data.source && + FileUtils.isExcelFile($scope.currentFilename) + ) { + try { + // Convert to number if it's a string (from ng-value) + var index = + typeof worksheetIndex === "string" + ? parseInt(worksheetIndex, 10) + : worksheetIndex; - // Re-parse the Excel file with the selected worksheet - $scope.data_object.parseExcel( - $scope.data.source.data, - $scope.currentFilename, - $scope.file.chosenEncoding, - $scope.file.startAtRow, - $scope.profile.extraRow, - $scope.file.chosenDelimiter == "auto" ? null : $scope.file.chosenDelimiter, - index - ); + // Re-parse the Excel file with the selected worksheet + $scope.data_object.parseExcel( + $scope.data.source.data, + $scope.currentFilename, + $scope.file.chosenEncoding, + $scope.file.startAtRow, + $scope.profile.extraRow, + $scope.file.chosenDelimiter == "auto" + ? null + : $scope.file.chosenDelimiter, + index, + ); - $scope.preview = $scope.data_object.converted_json(10, $scope.ynab_cols, $scope.ynab_map, $scope.inverted_outflow); - $scope.$evalAsync(); - } catch (error) { - console.error('Error switching worksheet:', error); - alert('Error switching worksheet: ' + error.message); + $scope.preview = $scope.data_object.converted_json( + 10, + $scope.ynab_cols, + $scope.ynab_map, + $scope.inverted_outflow, + ); + $scope.$evalAsync(); + } catch (error) { + console.error("Error switching worksheet:", error); + alert("Error switching worksheet: " + error.message); + } } - } - }; - $scope.downloadFile = function () { - var a; - var date = new Date(); - a = document.createElement("a"); - a.href = - "data:attachment/csv;base64," + - btoa(unescape(encodeURIComponent($scope.csvString()))); - a.target = "_blank"; - a.download = `ynab_data_${date.yyyymmdd()}.csv`; - document.body.appendChild(a); - a.click(); - }; - }); + }; + $scope.downloadFile = function () { + var a; + var date = new Date(); + a = document.createElement("a"); + a.href = + "data:attachment/csv;base64," + + btoa(unescape(encodeURIComponent($scope.csvString()))); + a.target = "_blank"; + a.download = `ynab_data_${date.yyyymmdd()}.csv`; + document.body.appendChild(a); + a.click(); + }; + }); angular.bootstrap(document, ["app"]); }); - - diff --git a/src/data_object.js b/src/data_object.js index 8e7ed1a..4ad3c17 100644 --- a/src/data_object.js +++ b/src/data_object.js @@ -11,36 +11,46 @@ window.DataObject = class DataObject { } // Parse Excel file and convert to CSV format that existing parseCsv can handle - parseExcel(fileContent, filename, encoding, startAtRow=1, extraRow=false, delimiter=null, worksheetIndex=0) { + parseExcel( + fileContent, + filename, + encoding, + startAtRow = 1, + extraRow = false, + delimiter = null, + worksheetIndex = 0, + ) { try { // Determine the appropriate data type for SheetJS based on file format - let dataType = 'binary'; + let dataType = "binary"; - if (FileUtils.getExcelReadingMethod(filename) === 'arrayBuffer') { + if (FileUtils.getExcelReadingMethod(filename) === "arrayBuffer") { // XLS and XLSB files use OLE2 format and should be read as array buffer - dataType = 'array'; + dataType = "array"; // Convert ArrayBuffer to Uint8Array if needed if (fileContent instanceof ArrayBuffer) { fileContent = new Uint8Array(fileContent); } } else { // XLSX and XLSM files are ZIP-based and can be read as binary string - dataType = 'binary'; + dataType = "binary"; } // Read the Excel file using SheetJS with appropriate data type const workbook = XLSX.read(fileContent, { type: dataType }); // Validate that we have a proper workbook - if (!workbook || typeof workbook !== 'object') { - throw new Error('Invalid Excel file: Unable to parse workbook structure'); + if (!workbook || typeof workbook !== "object") { + throw new Error( + "Invalid Excel file: Unable to parse workbook structure", + ); } // Get worksheet names for potential multi-sheet support const worksheetNames = workbook.SheetNames; if (!worksheetNames || worksheetNames.length === 0) { - throw new Error('No worksheets found in Excel file'); + throw new Error("No worksheets found in Excel file"); } // Use specified worksheet index or default to first sheet @@ -50,7 +60,9 @@ window.DataObject = class DataObject { } else if (worksheetIndex === 0 || worksheetIndex === undefined) { worksheetName = worksheetNames[0]; } else { - throw new Error(`Worksheet index ${worksheetIndex} is out of range. Available sheets: ${worksheetNames.length}`); + throw new Error( + `Worksheet index ${worksheetIndex} is out of range. Available sheets: ${worksheetNames.length}`, + ); } const worksheet = workbook.Sheets[worksheetName]; @@ -64,12 +76,14 @@ window.DataObject = class DataObject { // Validate that we got meaningful CSV content if (!csvContent || csvContent.trim().length === 0) { - throw new Error('No data found in selected worksheet'); + throw new Error("No data found in selected worksheet"); } // Additional validation: check if content looks like binary garbage if (this._containsBinaryGarbage(csvContent)) { - throw new Error('Excel file appears to be corrupted or in an unsupported format'); + throw new Error( + "Excel file appears to be corrupted or in an unsupported format", + ); } // Store worksheet info for potential UI use @@ -77,10 +91,15 @@ window.DataObject = class DataObject { this.currentWorksheet = worksheetName; // Now parse the CSV content using existing parseCsv method - return this.parseCsv(csvContent, encoding, startAtRow, extraRow, delimiter); - + return this.parseCsv( + csvContent, + encoding, + startAtRow, + extraRow, + delimiter, + ); } catch (error) { - console.error('Error parsing Excel file:', error); + console.error("Error parsing Excel file:", error); throw new Error(`Failed to parse Excel file: ${error.message}`); } } @@ -88,40 +107,42 @@ window.DataObject = class DataObject { // Helper method to detect if CSV content contains binary garbage _containsBinaryGarbage(csvContent) { // Check for excessive non-printable characters - const nonPrintableCount = (csvContent.match(/[\x00-\x08\x0E-\x1F\x7F-\xFF]/g) || []).length; + const nonPrintableCount = ( + csvContent.match(/[\x00-\x08\x0E-\x1F\x7F-\xFF]/g) || [] + ).length; const totalLength = csvContent.length; // If more than 30% of content is non-printable, it's likely binary garbage - return totalLength > 0 && (nonPrintableCount / totalLength) > 0.3; + return totalLength > 0 && nonPrintableCount / totalLength > 0.3; } // Parse base csv file as JSON. This will be easier to work with. // It uses http://papaparse.com/ for handling parsing - parseCsv(csv, encoding, startAtRow=1, extraRow=false, delimiter=null) { + parseCsv(csv, encoding, startAtRow = 1, extraRow = false, delimiter = null) { let existingHeaders = []; let config = { header: true, skipEmptyLines: true, - beforeFirstChunk: function(chunk) { + beforeFirstChunk: function (chunk) { var rows = chunk.split("\n"); var startIndex = startAtRow - 1; rows = rows.slice(startIndex); if (extraRow) { - // If first row duplication is turned on, we add the first row to the top of the set again. + // If first row duplication is turned on, we add the first row to the top of the set again. rows.unshift(rows[0]); } return rows.join("\n"); }, - transformHeader: function(header) { + transformHeader: function (header) { if (header.trim().length == 0) { header = "Unnamed column"; } if (existingHeaders.indexOf(header) != -1) { let new_header = header; let counter = 0; - while(existingHeaders.indexOf(new_header) != -1){ + while (existingHeaders.indexOf(new_header) != -1) { counter++; new_header = header + " (" + counter + ")"; } @@ -129,10 +150,10 @@ window.DataObject = class DataObject { } existingHeaders.push(header); return header; - } - } + }, + }; if (delimiter !== null) { - config.delimiter = delimiter + config.delimiter = delimiter; } var result = Papa.parse(csv, config); @@ -177,26 +198,25 @@ window.DataObject = class DataObject { if (cell) { switch (col) { case "Outflow": - - if (lookup['Outflow'] == lookup['Inflow']) { + if (lookup["Outflow"] == lookup["Inflow"]) { if (!inverted_outflow) { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : ""; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : ""; } else { - tmp_row[col] = cell.startsWith('-') ? "" : cell; + tmp_row[col] = cell.startsWith("-") ? "" : cell; } } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : cell; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : cell; } break; case "Inflow": - if (lookup['Outflow'] == lookup['Inflow']) { + if (lookup["Outflow"] == lookup["Inflow"]) { if (!inverted_outflow) { - tmp_row[col] = cell.startsWith('-') ? "" : cell; + tmp_row[col] = cell.startsWith("-") ? "" : cell; } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : ""; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : ""; } } else { - tmp_row[col] = cell.startsWith('-') ? cell.slice(1) : cell; + tmp_row[col] = cell.startsWith("-") ? cell.slice(1) : cell; } break; default: @@ -218,18 +238,20 @@ window.DataObject = class DataObject { } // Papa.unparse string string = '"' + ynab_cols.join('","') + '"\n'; - this.converted_json(limit, ynab_cols, lookup, inverted_outflow).forEach(function (row) { - var row_values; - row_values = []; - ynab_cols.forEach(function (col) { - var row_value; - row_value = row[col] || ""; - // escape text which might already have a quote in it - row_value = row_value.replace(/"/g, '""').trim(); - return row_values.push(row_value); - }); - return (string += '"' + row_values.join('","') + '"\n'); - }); + this.converted_json(limit, ynab_cols, lookup, inverted_outflow).forEach( + function (row) { + var row_values; + row_values = []; + ynab_cols.forEach(function (col) { + var row_value; + row_value = row[col] || ""; + // escape text which might already have a quote in it + row_value = row_value.replace(/"/g, '""').trim(); + return row_values.push(row_value); + }); + return (string += '"' + row_values.join('","') + '"\n'); + }, + ); return string; } }; diff --git a/src/file_utils.js b/src/file_utils.js index 22df580..65fe343 100644 --- a/src/file_utils.js +++ b/src/file_utils.js @@ -1,14 +1,14 @@ // File processing utility functions -(function() { - 'use strict'; +(function () { + "use strict"; // File extension constants - var EXCEL_EXTENSIONS = ['xlsx', 'xls', 'xlsm', 'xlsb']; - var BINARY_FORMAT_EXTENSIONS = ['xls', 'xlsb']; + var EXCEL_EXTENSIONS = ["xlsx", "xls", "xlsm", "xlsb"]; + var BINARY_FORMAT_EXTENSIONS = ["xls", "xlsb"]; function getFileExtension(filename) { - if (!filename) return ''; - return filename.toLowerCase().split('.').pop(); + if (!filename) return ""; + return filename.toLowerCase().split(".").pop(); } function isExcelFile(filename) { @@ -19,29 +19,31 @@ function getExcelReadingMethod(filename) { var extension = getFileExtension(filename); - return BINARY_FORMAT_EXTENSIONS.includes(extension) ? 'arrayBuffer' : 'binaryString'; + return BINARY_FORMAT_EXTENSIONS.includes(extension) + ? "arrayBuffer" + : "binaryString"; } function getFileType(filename) { - if (!filename) return ''; + if (!filename) return ""; var extension = getFileExtension(filename); if (EXCEL_EXTENSIONS.includes(extension)) { return extension.toUpperCase(); } - return 'CSV'; + return "CSV"; } function getFileTypeConstants() { return { EXCEL_EXTENSIONS: EXCEL_EXTENSIONS, - BINARY_FORMAT_EXTENSIONS: BINARY_FORMAT_EXTENSIONS + BINARY_FORMAT_EXTENSIONS: BINARY_FORMAT_EXTENSIONS, }; } function createDataWrapper(content, filename) { return { data: content, - filename: filename + filename: filename, }; } @@ -52,18 +54,17 @@ getExcelReadingMethod: getExcelReadingMethod, getFileType: getFileType, constants: getFileTypeConstants, - createDataWrapper: createDataWrapper + createDataWrapper: createDataWrapper, }; // Support both browser and Node.js environments - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.FileUtils = FileUtils; } - if (typeof module !== 'undefined' && module.exports) { + if (typeof module !== "undefined" && module.exports) { module.exports = FileUtils; } - if (typeof global !== 'undefined') { + if (typeof global !== "undefined") { global.FileUtils = FileUtils; } })(); - diff --git a/tests/app.test.js b/tests/app.test.js index aeae543..d620168 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -2,15 +2,15 @@ const mockModule = { directive: jest.fn(() => mockModule), config: jest.fn(() => mockModule), - controller: jest.fn(() => mockModule) + controller: jest.fn(() => mockModule), }; global.angular = { element: jest.fn(() => ({ - ready: jest.fn((callback) => callback()) + ready: jest.fn((callback) => callback()), })), module: jest.fn(() => mockModule), - bootstrap: jest.fn() + bootstrap: jest.fn(), }; // Mock DataObject @@ -23,25 +23,25 @@ global.DataObject = jest.fn(() => ({ fields: jest.fn(() => []), rows: jest.fn(() => []), worksheetNames: [], - currentWorksheet: null + currentWorksheet: null, })); // Mock document global.document = { createElement: jest.fn(() => ({ - click: jest.fn() + click: jest.fn(), })), body: { - appendChild: jest.fn() - } + appendChild: jest.fn(), + }, }; // Mock Date prototype -global.Date.prototype.yyyymmdd = function() { - return '20240101'; +global.Date.prototype.yyyymmdd = function () { + return "20240101"; }; -describe('ParseController', () => { +describe("ParseController", () => { let $scope; let $location; let controller; @@ -54,90 +54,115 @@ describe('ParseController', () => { // Create mock $scope $scope = { $watch: jest.fn(), - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), }; // Create mock $location $location = { - search: jest.fn(() => ({})) + search: jest.fn(() => ({})), }; // Load the app.js file to register the controller - require('../src/app.js'); + require("../src/app.js"); // Get the controller function that was registered const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; // Execute the controller controller = controllerFn($scope, $location); }); - describe('Profile Management', () => { - test('should initialize with default profile settings', () => { - expect($scope.profileName).toBe('default profile'); - expect($scope.profiles).toHaveProperty('default profile'); + describe("Profile Management", () => { + test("should initialize with default profile settings", () => { + expect($scope.profileName).toBe("default profile"); + expect($scope.profiles).toHaveProperty("default profile"); expect($scope.profile).toBeDefined(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); - expect($scope.file.chosenEncoding).toBe('UTF-8'); - expect($scope.file.chosenDelimiter).toBe('auto'); + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); + expect($scope.file.chosenEncoding).toBe("UTF-8"); + expect($scope.file.chosenDelimiter).toBe("auto"); }); - test('should detect non-default profiles exist', () => { + test("should detect non-default profiles exist", () => { // Initially only has default profile - $scope.profiles = { 'default profile': {} }; + $scope.profiles = { "default profile": {} }; expect($scope.nonDefaultProfilesExist()).toBe(false); // Add another profile - $scope.profiles['custom-profile'] = {}; + $scope.profiles["custom-profile"] = {}; expect($scope.nonDefaultProfilesExist()).toBe(true); }); - test('should toggle between old and new column formats', () => { + test("should toggle between old and new column formats", () => { // Start with old format - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); // Toggle to new format $scope.toggleColumnFormat(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Amount']); - expect($scope.profile.columnFormat).toEqual(['Date', 'Payee', 'Memo', 'Amount']); + expect($scope.ynab_cols).toEqual(["Date", "Payee", "Memo", "Amount"]); + expect($scope.profile.columnFormat).toEqual([ + "Date", + "Payee", + "Memo", + "Amount", + ]); // Toggle back to old format $scope.toggleColumnFormat(); - expect($scope.ynab_cols).toEqual(['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']); + expect($scope.ynab_cols).toEqual([ + "Date", + "Payee", + "Memo", + "Outflow", + "Inflow", + ]); }); - test('should update profile encoding when chosen', () => { - $scope.encodingChosen('windows-1252'); - expect($scope.profile.chosenEncoding).toBe('windows-1252'); + test("should update profile encoding when chosen", () => { + $scope.encodingChosen("windows-1252"); + expect($scope.profile.chosenEncoding).toBe("windows-1252"); }); - test('should update profile delimiter when chosen', () => { - $scope.delimiterChosen(';'); - expect($scope.profile.chosenDelimiter).toBe(';'); + test("should update profile delimiter when chosen", () => { + $scope.delimiterChosen(";"); + expect($scope.profile.chosenDelimiter).toBe(";"); }); - test('should update start row when set', () => { + test("should update start row when set", () => { $scope.startRowSet(3); expect($scope.profile.startAtRow).toBe(3); }); - test('should update extra row setting when set', () => { + test("should update extra row setting when set", () => { $scope.extraRowSet(true); expect($scope.profile.extraRow).toBe(true); }); - test('should switch profiles and call URL search', () => { - $scope.profileChosen('new-profile'); + test("should switch profiles and call URL search", () => { + $scope.profileChosen("new-profile"); - expect($location.search).toHaveBeenCalledWith('profile', 'new-profile'); + expect($location.search).toHaveBeenCalledWith("profile", "new-profile"); // Note: The function uses $scope.profileName instead of the parameter, // so it will use the existing profile, not the new one expect($scope.profile).toBe($scope.profiles[$scope.profileName]); }); - test('should invert flows when toggle is called', () => { + test("should invert flows when toggle is called", () => { expect($scope.inverted_outflow).toBe(false); $scope.invert_flows(); @@ -147,7 +172,7 @@ describe('ParseController', () => { expect($scope.inverted_outflow).toBe(false); }); - test('should reset app state when reloadApp is called', () => { + test("should reset app state when reloadApp is called", () => { $scope.setInitialScopeState = jest.fn(); $scope.reloadApp(); @@ -155,17 +180,19 @@ describe('ParseController', () => { }); }); - describe('File Processing', () => { + describe("File Processing", () => { beforeEach(() => { // Set up data object mock methods $scope.data_object.parseCsv = jest.fn(); $scope.data_object.converted_json = jest.fn(() => [ - { Date: '2024-01-01', Payee: 'Store', Amount: '-50.00' } + { Date: "2024-01-01", Payee: "Store", Amount: "-50.00" }, ]); - $scope.data_object.converted_csv = jest.fn(() => 'Date,Payee,Amount\n2024-01-01,Store,-50.00'); + $scope.data_object.converted_csv = jest.fn( + () => "Date,Payee,Amount\n2024-01-01,Store,-50.00", + ); }); - test('should process file data when data.source changes', () => { + test("should process file data when data.source changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -174,38 +201,40 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Simulate file data change with auto delimiter - $scope.file.chosenDelimiter = 'auto'; + $scope.file.chosenDelimiter = "auto"; $scope.file.startAtRow = 1; $scope.profile.extraRow = false; const csvData = { - data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', - filename: 'test.csv' + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", }; - watchCallbacks['data.source'](csvData, null); + watchCallbacks["data.source"](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, - $scope.profile.extraRow + $scope.profile.extraRow, ); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, ); }); - test('should process file data with custom delimiter', () => { + test("should process file data with custom delimiter", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -214,33 +243,35 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Simulate file data change with custom delimiter - $scope.file.chosenDelimiter = ';'; + $scope.file.chosenDelimiter = ";"; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; const csvData = { - data: 'Date;Payee;Amount\n2024-01-01;Store;-50.00', - filename: 'test.csv' + data: "Date;Payee;Amount\n2024-01-01;Store;-50.00", + filename: "test.csv", }; - watchCallbacks['data.source'](csvData, null); + watchCallbacks["data.source"](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, - ';' + ";", ); }); - test('should update preview when inverted_outflow changes', () => { + test("should update preview when inverted_outflow changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -249,9 +280,11 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); @@ -260,17 +293,17 @@ describe('ParseController', () => { // Simulate inverted outflow change - the watch callback should update preview $scope.inverted_outflow = true; - watchCallbacks['inverted_outflow'](true, false); + watchCallbacks["inverted_outflow"](true, false); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, ); }); - test('should update preview when column mapping changes', () => { + test("should update preview when column mapping changes", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback, deep) => { @@ -279,27 +312,31 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Simulate column mapping change - const newMapping = { Date: 'Transaction Date', Payee: 'Merchant' }; - watchCallbacks['ynab_map'](newMapping, {}); + const newMapping = { Date: "Transaction Date", Payee: "Merchant" }; + watchCallbacks["ynab_map"](newMapping, {}); expect($scope.profile.chosenColumns).toEqual(newMapping); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, newMapping, - $scope.inverted_outflow + $scope.inverted_outflow, ); }); - test('should generate CSV string for download', () => { - $scope.data_object.converted_csv.mockReturnValue('Date,Payee,Amount\n2024-01-01,Store,-50.00'); + test("should generate CSV string for download", () => { + $scope.data_object.converted_csv.mockReturnValue( + "Date,Payee,Amount\n2024-01-01,Store,-50.00", + ); const result = $scope.csvString(); @@ -307,47 +344,52 @@ describe('ParseController', () => { null, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, ); - expect(result).toBe('Date,Payee,Amount\n2024-01-01,Store,-50.00'); + expect(result).toBe("Date,Payee,Amount\n2024-01-01,Store,-50.00"); }); - test('should create download file with proper filename and encoding', () => { + test("should create download file with proper filename and encoding", () => { const mockAnchor = { - href: '', - target: '', - download: '', - click: jest.fn() + href: "", + target: "", + download: "", + click: jest.fn(), }; // Mock Date constructor to return a specific date - const mockDate = new Date('2024-01-01'); - mockDate.yyyymmdd = jest.fn(() => '20240101'); - jest.spyOn(global, 'Date').mockImplementation(() => mockDate); + const mockDate = new Date("2024-01-01"); + mockDate.yyyymmdd = jest.fn(() => "20240101"); + jest.spyOn(global, "Date").mockImplementation(() => mockDate); // Mock document.createElement to return our mock anchor global.document.createElement = jest.fn(() => mockAnchor); global.document.body.appendChild = jest.fn(); // Use realistic CSV data with special characters to test encoding - const csvData = 'Date,Payee,Memo,Amount\n2024-01-01,"Store ""ABC""","Café & Groceries","-$50.00"'; + const csvData = + 'Date,Payee,Memo,Amount\n2024-01-01,"Store ""ABC""","Café & Groceries","-$50.00"'; $scope.data_object.converted_csv.mockReturnValue(csvData); $scope.downloadFile(); // Verify DOM interactions - expect(global.document.createElement).toHaveBeenCalledWith('a'); - expect(mockAnchor.target).toBe('_blank'); - expect(mockAnchor.download).toBe('ynab_data_20240101.csv'); + expect(global.document.createElement).toHaveBeenCalledWith("a"); + expect(mockAnchor.target).toBe("_blank"); + expect(mockAnchor.download).toBe("ynab_data_20240101.csv"); expect(global.document.body.appendChild).toHaveBeenCalledWith(mockAnchor); expect(mockAnchor.click).toHaveBeenCalled(); // Verify the actual encoding pipeline: btoa(unescape(encodeURIComponent(csvData))) - const expectedHref = 'data:attachment/csv;base64,' + btoa(unescape(encodeURIComponent(csvData))); + const expectedHref = + "data:attachment/csv;base64," + + btoa(unescape(encodeURIComponent(csvData))); expect(mockAnchor.href).toBe(expectedHref); // Verify we can decode it back to the original CSV data - const base64Part = mockAnchor.href.split('data:attachment/csv;base64,')[1]; + const base64Part = mockAnchor.href.split( + "data:attachment/csv;base64,", + )[1]; const decodedData = decodeURIComponent(escape(atob(base64Part))); expect(decodedData).toBe(csvData); @@ -356,70 +398,69 @@ describe('ParseController', () => { }); }); - - describe('Worksheet Selection', () => { + describe("Worksheet Selection", () => { beforeEach(() => { // Set up Excel file scenario - $scope.currentFilename = 'test.xlsx'; + $scope.currentFilename = "test.xlsx"; $scope.data = { source: { - data: 'excel_binary_data', - filename: 'test.xlsx' - } + data: "excel_binary_data", + filename: "test.xlsx", + }, }; $scope.data_object.parseExcel = jest.fn(); $scope.data_object.converted_json = jest.fn(() => [ - { Date: '2024-01-01', Payee: 'Excel Store', Amount: '-75.00' } + { Date: "2024-01-01", Payee: "Excel Store", Amount: "-75.00" }, ]); $scope.$evalAsync = jest.fn(); global.alert = jest.fn(); }); - test('worksheetChosen should re-parse Excel with new worksheet index', () => { + test("worksheetChosen should re-parse Excel with new worksheet index", () => { $scope.worksheetChosen(2); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( - 'excel_binary_data', - 'test.xlsx', + "excel_binary_data", + "test.xlsx", $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, null, // auto delimiter becomes null - 2 // worksheet index + 2, // worksheet index ); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, ); }); - test('worksheetChosen should handle custom delimiter', () => { - $scope.file.chosenDelimiter = ';'; + test("worksheetChosen should handle custom delimiter", () => { + $scope.file.chosenDelimiter = ";"; $scope.worksheetChosen(1); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( - 'excel_binary_data', - 'test.xlsx', + "excel_binary_data", + "test.xlsx", $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, - ';', // custom delimiter - 1 + ";", // custom delimiter + 1, ); }); - test('worksheetChosen should handle parseExcel errors gracefully', () => { + test("worksheetChosen should handle parseExcel errors gracefully", () => { // Mock console.error and alert to avoid noise in tests global.console.error = jest.fn(); global.alert = jest.fn(); // Make parseExcel throw an error $scope.data_object.parseExcel.mockImplementation(() => { - throw new Error('Invalid worksheet'); + throw new Error("Invalid worksheet"); }); // This should not throw @@ -427,15 +468,20 @@ describe('ParseController', () => { $scope.worksheetChosen(5); }).not.toThrow(); - expect(global.console.error).toHaveBeenCalledWith('Error switching worksheet:', expect.any(Error)); - expect(global.alert).toHaveBeenCalledWith('Error switching worksheet: Invalid worksheet'); + expect(global.console.error).toHaveBeenCalledWith( + "Error switching worksheet:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error switching worksheet: Invalid worksheet", + ); // Clean up mocks delete global.console.error; delete global.alert; }); - test('worksheetChosen should do nothing if filename is not set', () => { + test("worksheetChosen should do nothing if filename is not set", () => { $scope.currentFilename = null; $scope.worksheetChosen(1); @@ -444,7 +490,7 @@ describe('ParseController', () => { expect($scope.data_object.converted_json).not.toHaveBeenCalled(); }); - test('worksheetChosen should do nothing if data.source is not set', () => { + test("worksheetChosen should do nothing if data.source is not set", () => { $scope.data.source = null; $scope.worksheetChosen(1); @@ -453,8 +499,8 @@ describe('ParseController', () => { expect($scope.data_object.converted_json).not.toHaveBeenCalled(); }); - test('worksheetChosen should do nothing for non-Excel files', () => { - $scope.currentFilename = 'test.csv'; + test("worksheetChosen should do nothing for non-Excel files", () => { + $scope.currentFilename = "test.csv"; $scope.worksheetChosen(1); @@ -463,18 +509,18 @@ describe('ParseController', () => { }); }); - describe('Excel File Processing Integration', () => { + describe("Excel File Processing Integration", () => { beforeEach(() => { // Set up data object mock methods for Excel $scope.data_object.parseExcel = jest.fn(); $scope.data_object.isExcelFile = jest.fn(); $scope.data_object.converted_json = jest.fn(() => [ - { Date: '2024-01-01', Payee: 'Excel Store', Amount: '-75.00' } + { Date: "2024-01-01", Payee: "Excel Store", Amount: "-75.00" }, ]); - $scope.data_object.worksheetNames = ['Sheet1', 'Data']; + $scope.data_object.worksheetNames = ["Sheet1", "Data"]; }); - test('data.source watcher should call parseExcel for Excel files', () => { + test("data.source watcher should call parseExcel for Excel files", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -483,48 +529,50 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Set up Excel file scenario $scope.data_object.isExcelFile.mockReturnValue(true); - $scope.file.chosenDelimiter = 'auto'; + $scope.file.chosenDelimiter = "auto"; $scope.file.startAtRow = 2; $scope.profile.extraRow = true; const excelData = { - data: 'excel_binary_data', - filename: 'test.xlsx' + data: "excel_binary_data", + filename: "test.xlsx", }; // Simulate Excel file data change - watchCallbacks['data.source'](excelData, null); + watchCallbacks["data.source"](excelData, null); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( excelData.data, - 'test.xlsx', + "test.xlsx", $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, null, // auto delimiter becomes null - 0 // default worksheet index + 0, // default worksheet index ); expect($scope.data_object.converted_json).toHaveBeenCalledWith( 10, $scope.ynab_cols, $scope.ynab_map, - $scope.inverted_outflow + $scope.inverted_outflow, ); // Verify worksheet initialization expect($scope.file.selectedWorksheet).toBe(0); }); - test('data.source watcher should call parseExcel with custom delimiter', () => { + test("data.source watcher should call parseExcel with custom delimiter", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -533,35 +581,37 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Set up Excel file scenario with custom delimiter $scope.data_object.isExcelFile.mockReturnValue(true); - $scope.file.chosenDelimiter = ';'; + $scope.file.chosenDelimiter = ";"; const excelData = { - data: 'excel_binary_data', - filename: 'test.xlsx' + data: "excel_binary_data", + filename: "test.xlsx", }; - watchCallbacks['data.source'](excelData, null); + watchCallbacks["data.source"](excelData, null); expect($scope.data_object.parseExcel).toHaveBeenCalledWith( excelData.data, - 'test.xlsx', + "test.xlsx", $scope.file.chosenEncoding, $scope.file.startAtRow, $scope.profile.extraRow, - ';', // custom delimiter - 0 + ";", // custom delimiter + 0, ); }); - test('data.source watcher should call parseCsv for CSV files', () => { + test("data.source watcher should call parseCsv for CSV files", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -570,34 +620,36 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Set up CSV file scenario $scope.data_object.isExcelFile.mockReturnValue(false); - $scope.file.chosenDelimiter = 'auto'; + $scope.file.chosenDelimiter = "auto"; const csvData = { - data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', - filename: 'test.csv' + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", }; - watchCallbacks['data.source'](csvData, null); + watchCallbacks["data.source"](csvData, null); expect($scope.data_object.parseCsv).toHaveBeenCalledWith( csvData.data, $scope.file.chosenEncoding, $scope.file.startAtRow, - $scope.profile.extraRow + $scope.profile.extraRow, ); expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); }); - test('data.source watcher should handle Excel parsing errors', () => { + test("data.source watcher should handle Excel parsing errors", () => { // Mock console.error and alert to avoid noise in tests global.console.error = jest.fn(); global.alert = jest.fn(); @@ -610,9 +662,11 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); @@ -621,28 +675,33 @@ describe('ParseController', () => { // Make parseExcel throw an error $scope.data_object.parseExcel.mockImplementation(() => { - throw new Error('Corrupted Excel file'); + throw new Error("Corrupted Excel file"); }); const excelData = { - data: 'corrupted_excel_data', - filename: 'test.xlsx' + data: "corrupted_excel_data", + filename: "test.xlsx", }; // This should not crash the watcher expect(() => { - watchCallbacks['data.source'](excelData, null); + watchCallbacks["data.source"](excelData, null); }).not.toThrow(); - expect(global.console.error).toHaveBeenCalledWith('Error parsing file:', expect.any(Error)); - expect(global.alert).toHaveBeenCalledWith('Error parsing file: Corrupted Excel file'); + expect(global.console.error).toHaveBeenCalledWith( + "Error parsing file:", + expect.any(Error), + ); + expect(global.alert).toHaveBeenCalledWith( + "Error parsing file: Corrupted Excel file", + ); // Clean up mocks delete global.console.error; delete global.alert; }); - test('data.source watcher should not process empty data', () => { + test("data.source watcher should not process empty data", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -651,22 +710,24 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Test empty data scenarios - watchCallbacks['data.source']('', null); - watchCallbacks['data.source'](null, null); - watchCallbacks['data.source'](undefined, null); + watchCallbacks["data.source"]("", null); + watchCallbacks["data.source"](null, null); + watchCallbacks["data.source"](undefined, null); expect($scope.data_object.parseExcel).not.toHaveBeenCalled(); expect($scope.data_object.parseCsv).not.toHaveBeenCalled(); }); - test('worksheet initialization should work for Excel files', () => { + test("worksheet initialization should work for Excel files", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -675,26 +736,28 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); // Set up Excel file scenario - $scope.filename = 'test.xlsx'; + $scope.filename = "test.xlsx"; $scope.data_object.isExcelFile.mockReturnValue(true); - $scope.data_object.worksheetNames = ['Sheet1', 'Data', 'Summary']; + $scope.data_object.worksheetNames = ["Sheet1", "Data", "Summary"]; - const excelData = 'excel_binary_data'; + const excelData = "excel_binary_data"; - watchCallbacks['data.source'](excelData, null); + watchCallbacks["data.source"](excelData, null); // Should initialize selectedWorksheet to 0 for multi-sheet Excel files expect($scope.file.selectedWorksheet).toBe(0); }); - test('worksheet initialization should not occur for CSV files', () => { + test("worksheet initialization should not occur for CSV files", () => { // Set up watchers const watchCallbacks = {}; $scope.$watch.mockImplementation((expr, callback) => { @@ -703,9 +766,11 @@ describe('ParseController', () => { // Re-initialize to capture watch callbacks jest.resetModules(); - require('../src/app.js'); + require("../src/app.js"); const controllerCalls = mockModule.controller.mock.calls; - const parseControllerCall = controllerCalls.find(call => call[0] === 'ParseController'); + const parseControllerCall = controllerCalls.find( + (call) => call[0] === "ParseController", + ); const controllerFn = parseControllerCall[1]; controllerFn($scope, $location); @@ -713,15 +778,14 @@ describe('ParseController', () => { $scope.data_object.isExcelFile.mockReturnValue(false); const csvData = { - data: 'Date,Payee,Amount\n2024-01-01,Store,-50.00', - filename: 'test.csv' + data: "Date,Payee,Amount\n2024-01-01,Store,-50.00", + filename: "test.csv", }; - watchCallbacks['data.source'](csvData, null); + watchCallbacks["data.source"](csvData, null); // Should not set selectedWorksheet for CSV files (it starts as 0 from setInitialScopeState) expect($scope.file.selectedWorksheet).toBe(0); }); }); }); - diff --git a/tests/data_object.test.js b/tests/data_object.test.js index ea1f6a1..02df46e 100644 --- a/tests/data_object.test.js +++ b/tests/data_object.test.js @@ -1,12 +1,12 @@ // Mock PapaParse global.Papa = { - parse: jest.fn() + parse: jest.fn(), }; // Import DataObject after mocking Papa -require('../src/data_object.js'); +require("../src/data_object.js"); -describe('DataObject', () => { +describe("DataObject", () => { let dataObject; beforeEach(() => { @@ -14,443 +14,506 @@ describe('DataObject', () => { jest.clearAllMocks(); }); - describe('CSV Parsing', () => { - test('should parse CSV with default parameters', () => { - const csvData = 'header1,header2\nvalue1,value2'; + describe("CSV Parsing", () => { + test("should parse CSV with default parameters", () => { + const csvData = "header1,header2\nvalue1,value2"; const mockParsedData = { - data: [{ header1: 'value1', header2: 'value2' }], - meta: { fields: ['header1', 'header2'] } + data: [{ header1: "value1", header2: "value2" }], + meta: { fields: ["header1", "header2"] }, }; - + global.Papa.parse.mockReturnValue(mockParsedData); - - const result = dataObject.parseCsv(csvData, 'UTF-8'); - - expect(global.Papa.parse).toHaveBeenCalledWith(csvData, expect.objectContaining({ - header: true, - skipEmptyLines: true - })); + + const result = dataObject.parseCsv(csvData, "UTF-8"); + + expect(global.Papa.parse).toHaveBeenCalledWith( + csvData, + expect.objectContaining({ + header: true, + skipEmptyLines: true, + }), + ); expect(result).toEqual(mockParsedData); expect(dataObject.base_json).toEqual(mockParsedData); }); - test('should parse CSV starting at specific row', () => { - const csvData = 'skip1\nskip2\nheader1,header2\nvalue1,value2'; - + test("should parse CSV starting at specific row", () => { + const csvData = "skip1\nskip2\nheader1,header2\nvalue1,value2"; + global.Papa.parse.mockImplementation((data, config) => { const transformed = config.beforeFirstChunk(data); - expect(transformed).toBe('header1,header2\nvalue1,value2'); + expect(transformed).toBe("header1,header2\nvalue1,value2"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8', 3); - + + dataObject.parseCsv(csvData, "UTF-8", 3); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should duplicate first data row when extraRow is true', () => { - const csvData = 'header1,header2\nvalue1,value2\nvalue3,value4'; - + test("should duplicate first data row when extraRow is true", () => { + const csvData = "header1,header2\nvalue1,value2\nvalue3,value4"; + global.Papa.parse.mockImplementation((data, config) => { const transformed = config.beforeFirstChunk(data); - const lines = transformed.split('\n'); - expect(lines[0]).toBe('header1,header2'); - expect(lines[1]).toBe('header1,header2'); // Duplicated - expect(lines[2]).toBe('value1,value2'); + const lines = transformed.split("\n"); + expect(lines[0]).toBe("header1,header2"); + expect(lines[1]).toBe("header1,header2"); // Duplicated + expect(lines[2]).toBe("value1,value2"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8', 1, true); - + + dataObject.parseCsv(csvData, "UTF-8", 1, true); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should use custom delimiter when specified', () => { - const csvData = 'header1;header2\nvalue1;value2'; - + test("should use custom delimiter when specified", () => { + const csvData = "header1;header2\nvalue1;value2"; + global.Papa.parse.mockReturnValue({ data: [], meta: { fields: [] } }); - - dataObject.parseCsv(csvData, 'UTF-8', 1, false, ';'); - - expect(global.Papa.parse).toHaveBeenCalledWith(csvData, expect.objectContaining({ - delimiter: ';' - })); + + dataObject.parseCsv(csvData, "UTF-8", 1, false, ";"); + + expect(global.Papa.parse).toHaveBeenCalledWith( + csvData, + expect.objectContaining({ + delimiter: ";", + }), + ); }); - test('should handle empty headers', () => { - const csvData = ',header2\nvalue1,value2'; - + test("should handle empty headers", () => { + const csvData = ",header2\nvalue1,value2"; + global.Papa.parse.mockImplementation((data, config) => { - const transformedHeader = config.transformHeader(' '); - expect(transformedHeader).toBe('Unnamed column'); + const transformedHeader = config.transformHeader(" "); + expect(transformedHeader).toBe("Unnamed column"); return { data: [], meta: { fields: [] } }; }); - - dataObject.parseCsv(csvData, 'UTF-8'); - + + dataObject.parseCsv(csvData, "UTF-8"); + expect(global.Papa.parse).toHaveBeenCalled(); }); - test('should handle duplicate headers', () => { - const csvData = 'header1,header1,header1\nvalue1,value2,value3'; - + test("should handle duplicate headers", () => { + const csvData = "header1,header1,header1\nvalue1,value2,value3"; + global.Papa.parse.mockImplementation((data, config) => { // Simulate header transformation const headers = []; - headers.push(config.transformHeader('header1')); // 'header1' - headers.push(config.transformHeader('header1')); // 'header1 (1)' - headers.push(config.transformHeader('header1')); // 'header1 (2)' - - expect(headers).toEqual(['header1', 'header1 (1)', 'header1 (2)']); + headers.push(config.transformHeader("header1")); // 'header1' + headers.push(config.transformHeader("header1")); // 'header1 (1)' + headers.push(config.transformHeader("header1")); // 'header1 (2)' + + expect(headers).toEqual(["header1", "header1 (1)", "header1 (2)"]); return { data: [], meta: { fields: headers } }; }); - - dataObject.parseCsv(csvData, 'UTF-8'); - + + dataObject.parseCsv(csvData, "UTF-8"); + expect(global.Papa.parse).toHaveBeenCalled(); }); }); - describe('Data Access Methods', () => { - test('fields() should return parsed fields', () => { - const mockFields = ['field1', 'field2', 'field3']; + describe("Data Access Methods", () => { + test("fields() should return parsed fields", () => { + const mockFields = ["field1", "field2", "field3"]; dataObject.base_json = { meta: { fields: mockFields } }; - + expect(dataObject.fields()).toEqual(mockFields); }); - test('rows() should return parsed data', () => { + test("rows() should return parsed data", () => { const mockRows = [ - { col1: 'val1', col2: 'val2' }, - { col1: 'val3', col2: 'val4' } + { col1: "val1", col2: "val2" }, + { col1: "val3", col2: "val4" }, ]; dataObject.base_json = { data: mockRows }; - + expect(dataObject.rows()).toEqual(mockRows); }); }); - describe('Data Conversion - converted_json()', () => { + describe("Data Conversion - converted_json()", () => { beforeEach(() => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase', 'Amount': '-50.00', 'Notes': 'Groceries' }, - { 'Date': '2024-01-02', 'Description': 'Salary', 'Amount': '1000.00', 'Notes': 'Monthly pay' }, - { 'Date': '2024-01-03', 'Description': 'Refund', 'Amount': '25.50', 'Notes': 'Return' } + { + Date: "2024-01-01", + Description: "Purchase", + Amount: "-50.00", + Notes: "Groceries", + }, + { + Date: "2024-01-02", + Description: "Salary", + Amount: "1000.00", + Notes: "Monthly pay", + }, + { + Date: "2024-01-03", + Description: "Refund", + Amount: "25.50", + Notes: "Return", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; }); - test('should convert to old YNAB format with separate Inflow/Outflow', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should convert to old YNAB format with separate Inflow/Outflow", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result).toHaveLength(3); expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Outflow': '50.00', - 'Inflow': '' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Outflow: "50.00", + Inflow: "", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Outflow': '', - 'Inflow': '1000.00' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Outflow: "", + Inflow: "1000.00", }); }); - test('should convert to new YNAB format with combined Amount column', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should convert to new YNAB format with combined Amount column", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result).toHaveLength(3); expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Amount': '-50.00' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Amount: "-50.00", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Amount': '1000.00' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Amount: "1000.00", }); }); - test('should handle inverted outflow logic', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should handle inverted outflow logic", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup, true); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries', - 'Outflow': '', - 'Inflow': '50.00' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", + Outflow: "", + Inflow: "50.00", }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Salary', - 'Memo': 'Monthly pay', - 'Outflow': '1000.00', - 'Inflow': '' + Date: "2024-01-02", + Payee: "Salary", + Memo: "Monthly pay", + Outflow: "1000.00", + Inflow: "", }); }); - test('should respect limit parameter for preview', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should respect limit parameter for preview", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_json(2, ynab_cols, lookup); - + expect(result).toHaveLength(2); - expect(result[0].Payee).toBe('Purchase'); - expect(result[1].Payee).toBe('Salary'); + expect(result[0].Payee).toBe("Purchase"); + expect(result[1].Payee).toBe("Salary"); }); - test('should handle missing fields gracefully', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Category']; + test("should handle missing fields gracefully", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Category"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Category': 'NonExistentField' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Category: "NonExistentField", }; - + const result = dataObject.converted_json(1, ynab_cols, lookup); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Purchase', - 'Memo': 'Groceries' + Date: "2024-01-01", + Payee: "Purchase", + Memo: "Groceries", // Category is undefined, so not included }); }); - test('should handle separate Outflow/Inflow columns correctly', () => { + test("should handle separate Outflow/Inflow columns correctly", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Payee': 'Store', 'Out': '50.00', 'In': '' }, - { 'Date': '2024-01-02', 'Payee': 'Work', 'Out': '', 'In': '1000.00' } + { Date: "2024-01-01", Payee: "Store", Out: "50.00", In: "" }, + { Date: "2024-01-02", Payee: "Work", Out: "", In: "1000.00" }, ], - meta: { fields: ['Date', 'Payee', 'Out', 'In'] } + meta: { fields: ["Date", "Payee", "Out", "In"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Outflow', 'Inflow']; + + const ynab_cols = ["Date", "Payee", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Payee', - 'Outflow': 'Out', - 'Inflow': 'In' + Date: "Date", + Payee: "Payee", + Outflow: "Out", + Inflow: "In", }; - + const result = dataObject.converted_json(null, ynab_cols, lookup); - + expect(result[0]).toEqual({ - 'Date': '2024-01-01', - 'Payee': 'Store', - 'Outflow': '50.00' + Date: "2024-01-01", + Payee: "Store", + Outflow: "50.00", // Inflow is empty string, so not included in result }); expect(result[1]).toEqual({ - 'Date': '2024-01-02', - 'Payee': 'Work', - 'Inflow': '1000.00' + Date: "2024-01-02", + Payee: "Work", + Inflow: "1000.00", // Outflow is empty string, so not included in result }); }); - test('should return null if base_json is null', () => { + test("should return null if base_json is null", () => { dataObject.base_json = null; - + const result = dataObject.converted_json(null, [], {}); - + expect(result).toBeNull(); }); }); - describe('CSV Export - converted_csv()', () => { + describe("CSV Export - converted_csv()", () => { beforeEach(() => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase', 'Amount': '-50.00', 'Notes': 'Groceries' }, - { 'Date': '2024-01-02', 'Description': 'Salary', 'Amount': '1000.00', 'Notes': 'Monthly pay' } + { + Date: "2024-01-01", + Description: "Purchase", + Amount: "-50.00", + Notes: "Groceries", + }, + { + Date: "2024-01-02", + Description: "Salary", + Amount: "1000.00", + Notes: "Monthly pay", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; }); - test('should export to CSV format with old YNAB columns', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Outflow', 'Inflow']; + test("should export to CSV format with old YNAB columns", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Outflow", "Inflow"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Outflow': 'Amount', - 'Inflow': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Outflow: "Amount", + Inflow: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); // header + 2 data rows expect(lines[0]).toBe('"Date","Payee","Memo","Outflow","Inflow"'); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","50.00",""'); expect(lines[2]).toBe('"2024-01-02","Salary","Monthly pay","","1000.00"'); }); - test('should export to CSV format with new YNAB columns', () => { - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + test("should export to CSV format with new YNAB columns", () => { + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); expect(lines[0]).toBe('"Date","Payee","Memo","Amount"'); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","-50.00"'); expect(lines[2]).toBe('"2024-01-02","Salary","Monthly pay","1000.00"'); }); - test('should properly escape quotes in values', () => { + test("should properly escape quotes in values", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Store "ABC"', 'Amount': '-50.00', 'Notes': 'Bought "stuff"' } + { + Date: "2024-01-01", + Description: 'Store "ABC"', + Amount: "-50.00", + Notes: 'Bought "stuff"', + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); - expect(lines[1]).toBe('"2024-01-01","Store ""ABC""","Bought ""stuff""","-50.00"'); + + const lines = result.trim().split("\n"); + expect(lines[1]).toBe( + '"2024-01-01","Store ""ABC""","Bought ""stuff""","-50.00"', + ); }); - test('should trim values', () => { + test("should trim values", () => { dataObject.base_json = { data: [ - { 'Date': ' 2024-01-01 ', 'Description': ' Purchase ', 'Amount': ' -50.00 ', 'Notes': ' Groceries ' } + { + Date: " 2024-01-01 ", + Description: " Purchase ", + Amount: " -50.00 ", + Notes: " Groceries ", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines[1]).toBe('"2024-01-01","Purchase","Groceries","-50.00"'); }); - test('should handle empty values', () => { + test("should handle empty values", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': '', 'Amount': '-50.00', 'Notes': null } + { + Date: "2024-01-01", + Description: "", + Amount: "-50.00", + Notes: null, + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(null, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines[1]).toBe('"2024-01-01","","","-50.00"'); }); - test('should respect limit parameter', () => { + test("should respect limit parameter", () => { dataObject.base_json = { data: [ - { 'Date': '2024-01-01', 'Description': 'Purchase 1', 'Amount': '-50.00', 'Notes': 'Note 1' }, - { 'Date': '2024-01-02', 'Description': 'Purchase 2', 'Amount': '-60.00', 'Notes': 'Note 2' }, - { 'Date': '2024-01-03', 'Description': 'Purchase 3', 'Amount': '-70.00', 'Notes': 'Note 3' } + { + Date: "2024-01-01", + Description: "Purchase 1", + Amount: "-50.00", + Notes: "Note 1", + }, + { + Date: "2024-01-02", + Description: "Purchase 2", + Amount: "-60.00", + Notes: "Note 2", + }, + { + Date: "2024-01-03", + Description: "Purchase 3", + Amount: "-70.00", + Notes: "Note 3", + }, ], - meta: { fields: ['Date', 'Description', 'Amount', 'Notes'] } + meta: { fields: ["Date", "Description", "Amount", "Notes"] }, }; - - const ynab_cols = ['Date', 'Payee', 'Memo', 'Amount']; + + const ynab_cols = ["Date", "Payee", "Memo", "Amount"]; const lookup = { - 'Date': 'Date', - 'Payee': 'Description', - 'Memo': 'Notes', - 'Amount': 'Amount' + Date: "Date", + Payee: "Description", + Memo: "Notes", + Amount: "Amount", }; - + const result = dataObject.converted_csv(2, ynab_cols, lookup); - - const lines = result.trim().split('\n'); + + const lines = result.trim().split("\n"); expect(lines).toHaveLength(3); // header + 2 rows (limited) - expect(lines[1]).toContain('Purchase 1'); - expect(lines[2]).toContain('Purchase 2'); + expect(lines[1]).toContain("Purchase 1"); + expect(lines[2]).toContain("Purchase 2"); }); }); - describe('Excel File Support', () => { + describe("Excel File Support", () => { beforeEach(() => { // Mock XLSX for Excel tests global.XLSX = { read: jest.fn(), utils: { - sheet_to_csv: jest.fn() - } + sheet_to_csv: jest.fn(), + }, }; }); @@ -458,306 +521,471 @@ describe('DataObject', () => { delete global.XLSX; }); - describe('isExcelFile()', () => { - test('should detect Excel files by extension', () => { - expect(dataObject.isExcelFile('test.xlsx')).toBe(true); - expect(dataObject.isExcelFile('test.xls')).toBe(true); - expect(dataObject.isExcelFile('test.xlsm')).toBe(true); - expect(dataObject.isExcelFile('test.xlsb')).toBe(true); - expect(dataObject.isExcelFile('TEST.XLSX')).toBe(true); // case insensitive + describe("isExcelFile()", () => { + test("should detect Excel files by extension", () => { + expect(dataObject.isExcelFile("test.xlsx")).toBe(true); + expect(dataObject.isExcelFile("test.xls")).toBe(true); + expect(dataObject.isExcelFile("test.xlsm")).toBe(true); + expect(dataObject.isExcelFile("test.xlsb")).toBe(true); + expect(dataObject.isExcelFile("TEST.XLSX")).toBe(true); // case insensitive }); - test('should not detect non-Excel files', () => { - expect(dataObject.isExcelFile('test.csv')).toBe(false); - expect(dataObject.isExcelFile('test.txt')).toBe(false); - expect(dataObject.isExcelFile('test.pdf')).toBe(false); - expect(dataObject.isExcelFile('')).toBe(false); + test("should not detect non-Excel files", () => { + expect(dataObject.isExcelFile("test.csv")).toBe(false); + expect(dataObject.isExcelFile("test.txt")).toBe(false); + expect(dataObject.isExcelFile("test.pdf")).toBe(false); + expect(dataObject.isExcelFile("")).toBe(false); expect(dataObject.isExcelFile(null)).toBe(false); expect(dataObject.isExcelFile(undefined)).toBe(false); }); }); - describe('parseExcel()', () => { - test('should parse Excel file with single worksheet', () => { + describe("parseExcel()", () => { + test("should parse Excel file with single worksheet", () => { const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Date,Description,Amount\n2024-01-01,Purchase,-50.00'; - + const mockCsvContent = + "Date,Description,Amount\n2024-01-01,Purchase,-50.00"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - + // Mock parseCsv to return expected data const mockParsedData = { - data: [{ Date: '2024-01-01', Description: 'Purchase', Amount: '-50.00' }], - meta: { fields: ['Date', 'Description', 'Amount'] } + data: [ + { Date: "2024-01-01", Description: "Purchase", Amount: "-50.00" }, + ], + meta: { fields: ["Date", "Description", "Amount"] }, }; dataObject.parseCsv = jest.fn().mockReturnValue(mockParsedData); - - const result = dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); - - expect(global.XLSX.read).toHaveBeenCalledWith('binary_data', { type: 'binary' }); - expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Sheet1']); - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + + const result = dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + ); + + expect(global.XLSX.read).toHaveBeenCalledWith("binary_data", { + type: "binary", + }); + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Sheet1"], + ); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); expect(result).toEqual(mockParsedData); - expect(dataObject.worksheetNames).toEqual(['Sheet1']); - expect(dataObject.currentWorksheet).toBe('Sheet1'); + expect(dataObject.worksheetNames).toEqual(["Sheet1"]); + expect(dataObject.currentWorksheet).toBe("Sheet1"); }); - test('should parse Excel file with multiple worksheets', () => { + test("should parse Excel file with multiple worksheets", () => { const mockWorkbook = { - SheetNames: ['Sheet1', 'Sheet2', 'Data'], + SheetNames: ["Sheet1", "Sheet2", "Data"], Sheets: { - 'Sheet1': { /* worksheet 1 data */ }, - 'Sheet2': { /* worksheet 2 data */ }, - 'Data': { /* data worksheet */ } - } + Sheet1: { + /* worksheet 1 data */ + }, + Sheet2: { + /* worksheet 2 data */ + }, + Data: { + /* data worksheet */ + }, + }, }; - const mockCsvContent = 'Name,Value\nTest,123'; - + const mockCsvContent = "Name,Value\nTest,123"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test selecting a specific worksheet (index 2 = 'Data') - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 2); - - expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Data']); - expect(dataObject.worksheetNames).toEqual(['Sheet1', 'Sheet2', 'Data']); - expect(dataObject.currentWorksheet).toBe('Data'); + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 2, + ); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Data"], + ); + expect(dataObject.worksheetNames).toEqual(["Sheet1", "Sheet2", "Data"]); + expect(dataObject.currentWorksheet).toBe("Data"); }); - test('should handle Excel parsing errors', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - + test("should handle Excel parsing errors", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + global.XLSX.read.mockImplementation(() => { - throw new Error('Invalid Excel file'); + throw new Error("Invalid Excel file"); }); - + expect(() => { - dataObject.parseExcel('invalid_data', 'test.xlsx', 'UTF-8'); - }).toThrow('Failed to parse Excel file: Invalid Excel file'); - - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + dataObject.parseExcel("invalid_data", "test.xlsx", "UTF-8"); + }).toThrow("Failed to parse Excel file: Invalid Excel file"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); - test('should handle empty workbook', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - + test("should handle empty workbook", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const mockWorkbook = { SheetNames: [], - Sheets: {} + Sheets: {}, }; - + global.XLSX.read.mockReturnValue(mockWorkbook); - + expect(() => { - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); - }).toThrow('Failed to parse Excel file: No worksheets found in Excel file'); - - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + dataObject.parseExcel("binary_data", "test.xlsx", "UTF-8"); + }).toThrow( + "Failed to parse Excel file: No worksheets found in Excel file", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); - test('should handle missing worksheet', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - + test("should handle missing worksheet", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; - + global.XLSX.read.mockReturnValue(mockWorkbook); - + expect(() => { - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 5); // invalid index - }).toThrow('Failed to parse Excel file: Worksheet index 5 is out of range. Available sheets: 1'); - - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 5, + ); // invalid index + }).toThrow( + "Failed to parse Excel file: Worksheet index 5 is out of range. Available sheets: 1", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); - test('should pass through all parseCsv parameters', () => { + test("should pass through all parseCsv parameters", () => { const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Date,Amount\n2024-01-01,-50.00'; - + const mockCsvContent = "Date,Amount\n2024-01-01,-50.00"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - - dataObject.parseExcel('binary_data', 'test.xlsx', 'ISO-8859-1', 3, true, ',', 0); - - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'ISO-8859-1', 3, true, ','); + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "ISO-8859-1", + 3, + true, + ",", + 0, + ); + + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "ISO-8859-1", + 3, + true, + ",", + ); }); - test('should handle XLS files with ArrayBuffer data type', () => { + test("should handle XLS files with ArrayBuffer data type", () => { const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Date,Amount\n2024-01-01,-50.00'; - + const mockCsvContent = "Date,Amount\n2024-01-01,-50.00"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test with ArrayBuffer for XLS file const arrayBuffer = new ArrayBuffer(100); - dataObject.parseExcel(arrayBuffer, 'test.xls', 'UTF-8'); - - expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { type: 'array' }); - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + dataObject.parseExcel(arrayBuffer, "test.xls", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { + type: "array", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); }); - test('should handle XLSB files with ArrayBuffer data type', () => { + test("should handle XLSB files with ArrayBuffer data type", () => { const mockWorkbook = { - SheetNames: ['Data'], + SheetNames: ["Data"], Sheets: { - 'Data': { /* worksheet data */ } - } + Data: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Name,Value\nTest,123'; - + const mockCsvContent = "Name,Value\nTest,123"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test with ArrayBuffer for XLSB file const arrayBuffer = new ArrayBuffer(200); - dataObject.parseExcel(arrayBuffer, 'data.xlsb', 'UTF-8'); - - expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { type: 'array' }); - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + dataObject.parseExcel(arrayBuffer, "data.xlsb", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(expect.any(Uint8Array), { + type: "array", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); }); - test('should handle XLSX files with binary string data type', () => { + test("should handle XLSX files with binary string data type", () => { const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Header1,Header2\nValue1,Value2'; - + const mockCsvContent = "Header1,Header2\nValue1,Value2"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test with binary string for XLSX file - const binaryString = 'binary_data_string'; - dataObject.parseExcel(binaryString, 'spreadsheet.xlsx', 'UTF-8'); - - expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { type: 'binary' }); - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + const binaryString = "binary_data_string"; + dataObject.parseExcel(binaryString, "spreadsheet.xlsx", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { + type: "binary", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); }); - test('should handle XLSM files with binary string data type', () => { + test("should handle XLSM files with binary string data type", () => { const mockWorkbook = { - SheetNames: ['Macros'], + SheetNames: ["Macros"], Sheets: { - 'Macros': { /* worksheet data */ } - } + Macros: { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Function,Result\nSUM,100'; - + const mockCsvContent = "Function,Result\nSUM,100"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test with binary string for XLSM file - const binaryString = 'xlsm_binary_data'; - dataObject.parseExcel(binaryString, 'macro_file.xlsm', 'UTF-8'); - - expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { type: 'binary' }); - expect(dataObject.parseCsv).toHaveBeenCalledWith(mockCsvContent, 'UTF-8', 1, false, null); + const binaryString = "xlsm_binary_data"; + dataObject.parseExcel(binaryString, "macro_file.xlsm", "UTF-8"); + + expect(global.XLSX.read).toHaveBeenCalledWith(binaryString, { + type: "binary", + }); + expect(dataObject.parseCsv).toHaveBeenCalledWith( + mockCsvContent, + "UTF-8", + 1, + false, + null, + ); }); - test('should handle corrupted Excel file with meaningful error', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + test("should handle corrupted Excel file with meaningful error", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); global.XLSX.read.mockImplementation(() => { - throw new Error('Corrupted file structure'); + throw new Error("Corrupted file structure"); }); expect(() => { - dataObject.parseExcel('corrupted_data', 'test.xlsx', 'UTF-8'); - }).toThrow('Failed to parse Excel file: Corrupted file structure'); + dataObject.parseExcel("corrupted_data", "test.xlsx", "UTF-8"); + }).toThrow("Failed to parse Excel file: Corrupted file structure"); - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); - test('should handle empty worksheet gracefully', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + test("should handle empty worksheet gracefully", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const mockWorkbook = { - SheetNames: ['EmptySheet'], + SheetNames: ["EmptySheet"], Sheets: { - 'EmptySheet': null - } + EmptySheet: null, + }, }; global.XLSX.read.mockReturnValue(mockWorkbook); expect(() => { - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8'); - }).toThrow('Failed to parse Excel file: Worksheet not found: EmptySheet'); - - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + dataObject.parseExcel("binary_data", "test.xlsx", "UTF-8"); + }).toThrow( + "Failed to parse Excel file: Worksheet not found: EmptySheet", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); - test('should handle worksheet with special characters in name', () => { + test("should handle worksheet with special characters in name", () => { const mockWorkbook = { - SheetNames: ['Data & Analysis', 'Summary (Final)'], + SheetNames: ["Data & Analysis", "Summary (Final)"], Sheets: { - 'Data & Analysis': { /* worksheet data */ }, - 'Summary (Final)': { /* worksheet data */ } - } + "Data & Analysis": { + /* worksheet data */ + }, + "Summary (Final)": { + /* worksheet data */ + }, + }, }; - const mockCsvContent = 'Special,Data\nTest,Value'; - + const mockCsvContent = "Special,Data\nTest,Value"; + global.XLSX.read.mockReturnValue(mockWorkbook); global.XLSX.utils.sheet_to_csv.mockReturnValue(mockCsvContent); - dataObject.parseCsv = jest.fn().mockReturnValue({ data: [], meta: { fields: [] } }); - + dataObject.parseCsv = jest + .fn() + .mockReturnValue({ data: [], meta: { fields: [] } }); + // Test selecting worksheet with special characters - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 1); - - expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith(mockWorkbook.Sheets['Summary (Final)']); - expect(dataObject.currentWorksheet).toBe('Summary (Final)'); + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 1, + ); + + expect(global.XLSX.utils.sheet_to_csv).toHaveBeenCalledWith( + mockWorkbook.Sheets["Summary (Final)"], + ); + expect(dataObject.currentWorksheet).toBe("Summary (Final)"); }); - test('should handle very large worksheet index gracefully', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + test("should handle very large worksheet index gracefully", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); const mockWorkbook = { - SheetNames: ['Sheet1'], + SheetNames: ["Sheet1"], Sheets: { - 'Sheet1': { /* worksheet data */ } - } + Sheet1: { + /* worksheet data */ + }, + }, }; global.XLSX.read.mockReturnValue(mockWorkbook); expect(() => { - dataObject.parseExcel('binary_data', 'test.xlsx', 'UTF-8', 1, false, null, 999); - }).toThrow('Failed to parse Excel file: Worksheet index 999 is out of range. Available sheets: 1'); - - expect(consoleSpy).toHaveBeenCalledWith('Error parsing Excel file:', expect.any(Error)); + dataObject.parseExcel( + "binary_data", + "test.xlsx", + "UTF-8", + 1, + false, + null, + 999, + ); + }).toThrow( + "Failed to parse Excel file: Worksheet index 999 is out of range. Available sheets: 1", + ); + + expect(consoleSpy).toHaveBeenCalledWith( + "Error parsing Excel file:", + expect.any(Error), + ); consoleSpy.mockRestore(); }); }); diff --git a/tests/directives.test.js b/tests/directives.test.js index d3463f3..0754e08 100644 --- a/tests/directives.test.js +++ b/tests/directives.test.js @@ -2,27 +2,27 @@ const mockModule = { directive: jest.fn(() => mockModule), config: jest.fn(() => mockModule), - controller: jest.fn(() => mockModule) + controller: jest.fn(() => mockModule), }; global.angular = { element: jest.fn(() => ({ - ready: jest.fn((callback) => callback()) + ready: jest.fn((callback) => callback()), })), module: jest.fn(() => mockModule), - bootstrap: jest.fn() + bootstrap: jest.fn(), }; -describe('AngularJS Directives', () => { +describe("AngularJS Directives", () => { beforeEach(() => { jest.clearAllMocks(); jest.resetModules(); - + // Load the app.js file to register the directives - require('../src/app.js'); + require("../src/app.js"); }); - describe('fileread directive', () => { + describe("fileread directive", () => { let directiveFactory; let mockElement; let mockScope; @@ -31,108 +31,116 @@ describe('AngularJS Directives', () => { beforeEach(() => { // Get the fileread directive factory const directiveCalls = mockModule.directive.mock.calls; - const filereadCall = directiveCalls.find(call => call[0] === 'fileread'); + const filereadCall = directiveCalls.find( + (call) => call[0] === "fileread", + ); // The directive is defined as an array with the function as the last element directiveFactory = filereadCall[1][0]; // Create mocks mockScope = { fileread: null, - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), }; - + mockElement = { - bind: jest.fn() + bind: jest.fn(), }; - + mockAttributes = { - encoding: 'UTF-8' + encoding: "UTF-8", }; }); - test('should register fileread directive', () => { + test("should register fileread directive", () => { expect(directiveFactory).toBeDefined(); - expect(typeof directiveFactory).toBe('function'); + expect(typeof directiveFactory).toBe("function"); }); - test('should configure directive with correct scope', () => { + test("should configure directive with correct scope", () => { const directiveConfig = directiveFactory(); - + expect(directiveConfig.scope).toEqual({ fileread: "=", - filename: "=" + filename: "=", }); }); - test('should bind change event to element', () => { + test("should bind change event to element", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - - expect(mockElement.bind).toHaveBeenCalledWith('change', expect.any(Function)); + + expect(mockElement.bind).toHaveBeenCalledWith( + "change", + expect.any(Function), + ); }); - test('should process file when change event occurs', () => { + test("should process file when change event occurs", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + // Get the change event handler const changeHandler = mockElement.bind.mock.calls[0][1]; - + // Mock FileReader const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + // Mock event const mockEvent = { target: { - files: [{ name: 'test.csv', size: 100 }] - } + files: [{ name: "test.csv", size: 100 }], + }, }; - + // Execute change handler changeHandler(mockEvent); - + expect(global.FileReader).toHaveBeenCalled(); - expect(mockReader.readAsText).toHaveBeenCalledWith(mockEvent.target.files[0], 'UTF-8'); + expect(mockReader.readAsText).toHaveBeenCalledWith( + mockEvent.target.files[0], + "UTF-8", + ); }); - test('should update scope when file is loaded', () => { + test("should update scope when file is loaded", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const changeHandler = mockElement.bind.mock.calls[0][1]; - + const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + const mockEvent = { - target: { files: [{ name: 'test.csv' }] } + target: { files: [{ name: "test.csv" }] }, }; - + changeHandler(mockEvent); - + // Simulate file load const loadEvent = { - target: { result: 'csv,data\ntest,value' } + target: { result: "csv,data\ntest,value" }, }; mockReader.onload(loadEvent); - + expect(mockScope.$apply).toHaveBeenCalled(); // For CSV files, the data is wrapped in an object with filename expect(mockScope.fileread).toEqual({ - data: 'csv,data\ntest,value', - filename: 'test.csv' + data: "csv,data\ntest,value", + filename: "test.csv", }); }); }); - describe('dropzone directive', () => { + describe("dropzone directive", () => { let directiveFactory; let mockElement; let mockScope; @@ -141,204 +149,221 @@ describe('AngularJS Directives', () => { beforeEach(() => { // Get the dropzone directive factory const directiveCalls = mockModule.directive.mock.calls; - const dropzoneCall = directiveCalls.find(call => call[0] === 'dropzone'); + const dropzoneCall = directiveCalls.find( + (call) => call[0] === "dropzone", + ); // The directive is defined as an array with the function as the last element directiveFactory = dropzoneCall[1][0]; mockScope = { dropzone: null, - $apply: jest.fn((fn) => fn && fn()) + $apply: jest.fn((fn) => fn && fn()), }; - + mockElement = { bind: jest.fn(), addClass: jest.fn(), - removeClass: jest.fn() + removeClass: jest.fn(), }; - + mockAttributes = { - encoding: 'UTF-8' + encoding: "UTF-8", }; }); - test('should register dropzone directive', () => { + test("should register dropzone directive", () => { expect(directiveFactory).toBeDefined(); - expect(typeof directiveFactory).toBe('function'); + expect(typeof directiveFactory).toBe("function"); }); - test('should configure directive correctly', () => { + test("should configure directive correctly", () => { const directiveConfig = directiveFactory(); - + expect(directiveConfig.transclude).toBe(true); expect(directiveConfig.replace).toBe(true); - expect(directiveConfig.template).toBe('
'); + expect(directiveConfig.template).toBe( + '
', + ); expect(directiveConfig.scope).toEqual({ dropzone: "=", - filename: "=" + filename: "=", }); }); - test('should bind drag and drop events', () => { + test("should bind drag and drop events", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - - const expectedEvents = ['dragenter', 'dragover', 'dragleave', 'drop', 'paste']; - expectedEvents.forEach(eventName => { - expect(mockElement.bind).toHaveBeenCalledWith(eventName, expect.any(Function)); + + const expectedEvents = [ + "dragenter", + "dragover", + "dragleave", + "drop", + "paste", + ]; + expectedEvents.forEach((eventName) => { + expect(mockElement.bind).toHaveBeenCalledWith( + eventName, + expect.any(Function), + ); }); }); - test('should handle dragenter event', () => { + test("should handle dragenter event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + // Get the dragenter handler const bindCalls = mockElement.bind.mock.calls; - const dragenterCall = bindCalls.find(call => call[0] === 'dragenter'); + const dragenterCall = bindCalls.find((call) => call[0] === "dragenter"); const dragenterHandler = dragenterCall[1]; - + const mockEvent = { - preventDefault: jest.fn() + preventDefault: jest.fn(), }; - + dragenterHandler(mockEvent); - - expect(mockElement.addClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.addClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); }); - test('should handle dragover event', () => { + test("should handle dragover event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dragoverCall = bindCalls.find(call => call[0] === 'dragover'); + const dragoverCall = bindCalls.find((call) => call[0] === "dragover"); const dragoverHandler = dragoverCall[1]; - + const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { - effectAllowed: 'move', - dropEffect: null - } + effectAllowed: "move", + dropEffect: null, + }, }; - + dragoverHandler(mockEvent); - - expect(mockElement.addClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.addClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockEvent.stopPropagation).toHaveBeenCalled(); - expect(mockEvent.dataTransfer.dropEffect).toBe('move'); + expect(mockEvent.dataTransfer.dropEffect).toBe("move"); }); - test('should handle dragleave event', () => { + test("should handle dragleave event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dragleaveCall = bindCalls.find(call => call[0] === 'dragleave'); + const dragleaveCall = bindCalls.find((call) => call[0] === "dragleave"); const dragleaveHandler = dragleaveCall[1]; - + const mockEvent = { - preventDefault: jest.fn() + preventDefault: jest.fn(), }; - + dragleaveHandler(mockEvent); - - expect(mockElement.removeClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.removeClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); }); - test('should handle drop event', () => { + test("should handle drop event", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const dropCall = bindCalls.find(call => call[0] === 'drop'); + const dropCall = bindCalls.find((call) => call[0] === "drop"); const dropHandler = dropCall[1]; - + const mockReader = { onload: null, - readAsText: jest.fn() + readAsText: jest.fn(), }; global.FileReader = jest.fn(() => mockReader); - + // Mock the global 'file' variable that's used in the directive global.file = null; - + const mockEvent = { preventDefault: jest.fn(), stopPropagation: jest.fn(), dataTransfer: { - files: [{ name: 'dropped.csv', size: 200 }] - } + files: [{ name: "dropped.csv", size: 200 }], + }, }; - + dropHandler(mockEvent); - - expect(mockElement.removeClass).toHaveBeenCalledWith('dragging'); + + expect(mockElement.removeClass).toHaveBeenCalledWith("dragging"); expect(mockEvent.preventDefault).toHaveBeenCalled(); expect(mockEvent.stopPropagation).toHaveBeenCalled(); expect(global.FileReader).toHaveBeenCalled(); - expect(mockReader.readAsText).toHaveBeenCalledWith(mockEvent.dataTransfer.files[0], 'UTF-8'); + expect(mockReader.readAsText).toHaveBeenCalledWith( + mockEvent.dataTransfer.files[0], + "UTF-8", + ); }); - test('should handle paste event with text data', () => { + test("should handle paste event with text data", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const pasteCall = bindCalls.find(call => call[0] === 'paste'); + const pasteCall = bindCalls.find((call) => call[0] === "paste"); const pasteHandler = pasteCall[1]; - + // Mock the global 'data' variable that's used in the directive global.data = null; - + const mockDataItem = { - type: 'text/plain', - getAsString: jest.fn((callback) => callback('pasted,csv,data\nrow1,row2,row3')) + type: "text/plain", + getAsString: jest.fn((callback) => + callback("pasted,csv,data\nrow1,row2,row3"), + ), }; - + const mockEvent = { clipboardData: { - items: [mockDataItem] - } + items: [mockDataItem], + }, }; - + pasteHandler(mockEvent); - - expect(mockDataItem.getAsString).toHaveBeenCalledWith(expect.any(Function)); + + expect(mockDataItem.getAsString).toHaveBeenCalledWith( + expect.any(Function), + ); expect(mockScope.$apply).toHaveBeenCalled(); - expect(mockScope.dropzone).toBe('pasted,csv,data\nrow1,row2,row3'); + expect(mockScope.dropzone).toBe("pasted,csv,data\nrow1,row2,row3"); }); - test('should handle paste event with no text data', () => { + test("should handle paste event with no text data", () => { const directiveConfig = directiveFactory(); directiveConfig.link(mockScope, mockElement, mockAttributes); - + const bindCalls = mockElement.bind.mock.calls; - const pasteCall = bindCalls.find(call => call[0] === 'paste'); + const pasteCall = bindCalls.find((call) => call[0] === "paste"); const pasteHandler = pasteCall[1]; - + // Mock the global 'data' variable that's used in the directive global.data = null; - + const mockEvent = { clipboardData: { - items: [ - { type: 'image/png' }, - { type: 'application/json' } - ] - } + items: [{ type: "image/png" }, { type: "application/json" }], + }, }; - + pasteHandler(mockEvent); - + // Should not call $apply since no text/plain data found expect(mockScope.$apply).not.toHaveBeenCalled(); }); }); // TODO: Add Excel directive tests (currently complex due to sophisticated file handling logic) -}); \ No newline at end of file +}); diff --git a/tests/file_utils.test.js b/tests/file_utils.test.js index c70cf01..cad8ab4 100644 --- a/tests/file_utils.test.js +++ b/tests/file_utils.test.js @@ -1,149 +1,159 @@ -describe('FileUtils', () => { +describe("FileUtils", () => { let FileUtils; beforeEach(() => { // Clear any cached modules to ensure clean state - delete require.cache[require.resolve('../src/file_utils.js')]; + delete require.cache[require.resolve("../src/file_utils.js")]; // Re-require the module - FileUtils = require('../src/file_utils.js'); + FileUtils = require("../src/file_utils.js"); }); - describe('getFileExtension', () => { - test('should extract file extension correctly', () => { - expect(FileUtils.getFileExtension('test.xlsx')).toBe('xlsx'); - expect(FileUtils.getFileExtension('data.csv')).toBe('csv'); - expect(FileUtils.getFileExtension('file.XLS')).toBe('xls'); - expect(FileUtils.getFileExtension('document.XLSM')).toBe('xlsm'); + describe("getFileExtension", () => { + test("should extract file extension correctly", () => { + expect(FileUtils.getFileExtension("test.xlsx")).toBe("xlsx"); + expect(FileUtils.getFileExtension("data.csv")).toBe("csv"); + expect(FileUtils.getFileExtension("file.XLS")).toBe("xls"); + expect(FileUtils.getFileExtension("document.XLSM")).toBe("xlsm"); }); - test('should handle edge cases', () => { - expect(FileUtils.getFileExtension('')).toBe(''); - expect(FileUtils.getFileExtension(null)).toBe(''); - expect(FileUtils.getFileExtension(undefined)).toBe(''); - expect(FileUtils.getFileExtension('noextension')).toBe('noextension'); - expect(FileUtils.getFileExtension('file.')).toBe(''); - expect(FileUtils.getFileExtension('.hidden')).toBe('hidden'); + test("should handle edge cases", () => { + expect(FileUtils.getFileExtension("")).toBe(""); + expect(FileUtils.getFileExtension(null)).toBe(""); + expect(FileUtils.getFileExtension(undefined)).toBe(""); + expect(FileUtils.getFileExtension("noextension")).toBe("noextension"); + expect(FileUtils.getFileExtension("file.")).toBe(""); + expect(FileUtils.getFileExtension(".hidden")).toBe("hidden"); }); - test('should handle multiple dots', () => { - expect(FileUtils.getFileExtension('my.file.name.xlsx')).toBe('xlsx'); - expect(FileUtils.getFileExtension('archive.tar.gz')).toBe('gz'); + test("should handle multiple dots", () => { + expect(FileUtils.getFileExtension("my.file.name.xlsx")).toBe("xlsx"); + expect(FileUtils.getFileExtension("archive.tar.gz")).toBe("gz"); }); }); - describe('isExcelFile', () => { - test('should identify Excel files correctly', () => { - expect(FileUtils.isExcelFile('test.xlsx')).toBe(true); - expect(FileUtils.isExcelFile('data.xls')).toBe(true); - expect(FileUtils.isExcelFile('file.xlsm')).toBe(true); - expect(FileUtils.isExcelFile('workbook.xlsb')).toBe(true); + describe("isExcelFile", () => { + test("should identify Excel files correctly", () => { + expect(FileUtils.isExcelFile("test.xlsx")).toBe(true); + expect(FileUtils.isExcelFile("data.xls")).toBe(true); + expect(FileUtils.isExcelFile("file.xlsm")).toBe(true); + expect(FileUtils.isExcelFile("workbook.xlsb")).toBe(true); }); - test('should handle case insensitivity', () => { - expect(FileUtils.isExcelFile('TEST.XLSX')).toBe(true); - expect(FileUtils.isExcelFile('Data.XLS')).toBe(true); - expect(FileUtils.isExcelFile('FILE.Xlsm')).toBe(true); - expect(FileUtils.isExcelFile('WORKBOOK.xlsB')).toBe(true); + test("should handle case insensitivity", () => { + expect(FileUtils.isExcelFile("TEST.XLSX")).toBe(true); + expect(FileUtils.isExcelFile("Data.XLS")).toBe(true); + expect(FileUtils.isExcelFile("FILE.Xlsm")).toBe(true); + expect(FileUtils.isExcelFile("WORKBOOK.xlsB")).toBe(true); }); - test('should reject non-Excel files', () => { - expect(FileUtils.isExcelFile('data.csv')).toBe(false); - expect(FileUtils.isExcelFile('document.txt')).toBe(false); - expect(FileUtils.isExcelFile('image.png')).toBe(false); - expect(FileUtils.isExcelFile('data.json')).toBe(false); + test("should reject non-Excel files", () => { + expect(FileUtils.isExcelFile("data.csv")).toBe(false); + expect(FileUtils.isExcelFile("document.txt")).toBe(false); + expect(FileUtils.isExcelFile("image.png")).toBe(false); + expect(FileUtils.isExcelFile("data.json")).toBe(false); }); - test('should handle edge cases', () => { - expect(FileUtils.isExcelFile('')).toBe(false); + test("should handle edge cases", () => { + expect(FileUtils.isExcelFile("")).toBe(false); expect(FileUtils.isExcelFile(null)).toBe(false); expect(FileUtils.isExcelFile(undefined)).toBe(false); - expect(FileUtils.isExcelFile('noextension')).toBe(false); - expect(FileUtils.isExcelFile('file.')).toBe(false); + expect(FileUtils.isExcelFile("noextension")).toBe(false); + expect(FileUtils.isExcelFile("file.")).toBe(false); }); }); - describe('getExcelReadingMethod', () => { - test('should return arrayBuffer for binary formats', () => { - expect(FileUtils.getExcelReadingMethod('data.xls')).toBe('arrayBuffer'); - expect(FileUtils.getExcelReadingMethod('workbook.xlsb')).toBe('arrayBuffer'); - expect(FileUtils.getExcelReadingMethod('FILE.XLS')).toBe('arrayBuffer'); - expect(FileUtils.getExcelReadingMethod('WORKBOOK.XLSB')).toBe('arrayBuffer'); - }); - - test('should return binaryString for ZIP-based formats', () => { - expect(FileUtils.getExcelReadingMethod('data.xlsx')).toBe('binaryString'); - expect(FileUtils.getExcelReadingMethod('workbook.xlsm')).toBe('binaryString'); - expect(FileUtils.getExcelReadingMethod('FILE.XLSX')).toBe('binaryString'); - expect(FileUtils.getExcelReadingMethod('WORKBOOK.XLSM')).toBe('binaryString'); - }); - - test('should handle non-Excel files', () => { + describe("getExcelReadingMethod", () => { + test("should return arrayBuffer for binary formats", () => { + expect(FileUtils.getExcelReadingMethod("data.xls")).toBe("arrayBuffer"); + expect(FileUtils.getExcelReadingMethod("workbook.xlsb")).toBe( + "arrayBuffer", + ); + expect(FileUtils.getExcelReadingMethod("FILE.XLS")).toBe("arrayBuffer"); + expect(FileUtils.getExcelReadingMethod("WORKBOOK.XLSB")).toBe( + "arrayBuffer", + ); + }); + + test("should return binaryString for ZIP-based formats", () => { + expect(FileUtils.getExcelReadingMethod("data.xlsx")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("workbook.xlsm")).toBe( + "binaryString", + ); + expect(FileUtils.getExcelReadingMethod("FILE.XLSX")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("WORKBOOK.XLSM")).toBe( + "binaryString", + ); + }); + + test("should handle non-Excel files", () => { // Non-Excel files should default to binaryString - expect(FileUtils.getExcelReadingMethod('data.csv')).toBe('binaryString'); - expect(FileUtils.getExcelReadingMethod('document.txt')).toBe('binaryString'); - expect(FileUtils.getExcelReadingMethod('')).toBe('binaryString'); + expect(FileUtils.getExcelReadingMethod("data.csv")).toBe("binaryString"); + expect(FileUtils.getExcelReadingMethod("document.txt")).toBe( + "binaryString", + ); + expect(FileUtils.getExcelReadingMethod("")).toBe("binaryString"); }); }); - describe('getFileType', () => { - test('should return uppercase extension for Excel files', () => { - expect(FileUtils.getFileType('test.xlsx')).toBe('XLSX'); - expect(FileUtils.getFileType('data.xls')).toBe('XLS'); - expect(FileUtils.getFileType('file.xlsm')).toBe('XLSM'); - expect(FileUtils.getFileType('workbook.xlsb')).toBe('XLSB'); + describe("getFileType", () => { + test("should return uppercase extension for Excel files", () => { + expect(FileUtils.getFileType("test.xlsx")).toBe("XLSX"); + expect(FileUtils.getFileType("data.xls")).toBe("XLS"); + expect(FileUtils.getFileType("file.xlsm")).toBe("XLSM"); + expect(FileUtils.getFileType("workbook.xlsb")).toBe("XLSB"); }); - test('should handle case insensitivity for Excel files', () => { - expect(FileUtils.getFileType('TEST.xlsx')).toBe('XLSX'); - expect(FileUtils.getFileType('Data.XLS')).toBe('XLS'); - expect(FileUtils.getFileType('FILE.Xlsm')).toBe('XLSM'); - expect(FileUtils.getFileType('WORKBOOK.xlsB')).toBe('XLSB'); + test("should handle case insensitivity for Excel files", () => { + expect(FileUtils.getFileType("TEST.xlsx")).toBe("XLSX"); + expect(FileUtils.getFileType("Data.XLS")).toBe("XLS"); + expect(FileUtils.getFileType("FILE.Xlsm")).toBe("XLSM"); + expect(FileUtils.getFileType("WORKBOOK.xlsB")).toBe("XLSB"); }); - test('should return CSV for non-Excel files', () => { - expect(FileUtils.getFileType('data.csv')).toBe('CSV'); - expect(FileUtils.getFileType('document.txt')).toBe('CSV'); - expect(FileUtils.getFileType('image.png')).toBe('CSV'); - expect(FileUtils.getFileType('noextension')).toBe('CSV'); + test("should return CSV for non-Excel files", () => { + expect(FileUtils.getFileType("data.csv")).toBe("CSV"); + expect(FileUtils.getFileType("document.txt")).toBe("CSV"); + expect(FileUtils.getFileType("image.png")).toBe("CSV"); + expect(FileUtils.getFileType("noextension")).toBe("CSV"); }); - test('should handle edge cases', () => { - expect(FileUtils.getFileType('')).toBe(''); - expect(FileUtils.getFileType(null)).toBe(''); - expect(FileUtils.getFileType(undefined)).toBe(''); + test("should handle edge cases", () => { + expect(FileUtils.getFileType("")).toBe(""); + expect(FileUtils.getFileType(null)).toBe(""); + expect(FileUtils.getFileType(undefined)).toBe(""); }); }); - describe('createDataWrapper', () => { - test('should create data wrapper with content and filename', () => { - const content = 'test data content'; - const filename = 'test.csv'; + describe("createDataWrapper", () => { + test("should create data wrapper with content and filename", () => { + const content = "test data content"; + const filename = "test.csv"; const wrapper = FileUtils.createDataWrapper(content, filename); expect(wrapper).toEqual({ - data: 'test data content', - filename: 'test.csv' + data: "test data content", + filename: "test.csv", }); }); - test('should handle different content types', () => { + test("should handle different content types", () => { // Test with different data types const binaryData = new ArrayBuffer(8); - const wrapper1 = FileUtils.createDataWrapper(binaryData, 'test.xlsx'); + const wrapper1 = FileUtils.createDataWrapper(binaryData, "test.xlsx"); expect(wrapper1.data).toBe(binaryData); - expect(wrapper1.filename).toBe('test.xlsx'); + expect(wrapper1.filename).toBe("test.xlsx"); - const textData = 'csv,data,here'; - const wrapper2 = FileUtils.createDataWrapper(textData, 'data.csv'); + const textData = "csv,data,here"; + const wrapper2 = FileUtils.createDataWrapper(textData, "data.csv"); expect(wrapper2.data).toBe(textData); - expect(wrapper2.filename).toBe('data.csv'); + expect(wrapper2.filename).toBe("data.csv"); }); - test('should handle empty or null values', () => { - const wrapper1 = FileUtils.createDataWrapper('', ''); - expect(wrapper1.data).toBe(''); - expect(wrapper1.filename).toBe(''); + test("should handle empty or null values", () => { + const wrapper1 = FileUtils.createDataWrapper("", ""); + expect(wrapper1.data).toBe(""); + expect(wrapper1.filename).toBe(""); const wrapper2 = FileUtils.createDataWrapper(null, null); expect(wrapper2.data).toBe(null); @@ -151,41 +161,79 @@ describe('FileUtils', () => { }); }); - describe('constants', () => { - test('should provide file extension constants', () => { + describe("constants", () => { + test("should provide file extension constants", () => { const constants = FileUtils.constants(); - expect(constants.EXCEL_EXTENSIONS).toEqual(['xlsx', 'xls', 'xlsm', 'xlsb']); - expect(constants.BINARY_FORMAT_EXTENSIONS).toEqual(['xls', 'xlsb']); + expect(constants.EXCEL_EXTENSIONS).toEqual([ + "xlsx", + "xls", + "xlsm", + "xlsb", + ]); + expect(constants.BINARY_FORMAT_EXTENSIONS).toEqual(["xls", "xlsb"]); }); - test('should return immutable constants', () => { + test("should return immutable constants", () => { const constants1 = FileUtils.constants(); const constants2 = FileUtils.constants(); // Should return the same arrays each time expect(constants1.EXCEL_EXTENSIONS).toEqual(constants2.EXCEL_EXTENSIONS); - expect(constants1.BINARY_FORMAT_EXTENSIONS).toEqual(constants2.BINARY_FORMAT_EXTENSIONS); + expect(constants1.BINARY_FORMAT_EXTENSIONS).toEqual( + constants2.BINARY_FORMAT_EXTENSIONS, + ); }); }); - describe('integration tests', () => { - test('should work together consistently', () => { + describe("integration tests", () => { + test("should work together consistently", () => { const testFiles = [ - { name: 'data.xlsx', isExcel: true, method: 'binaryString', type: 'XLSX' }, - { name: 'workbook.xls', isExcel: true, method: 'arrayBuffer', type: 'XLS' }, - { name: 'file.xlsm', isExcel: true, method: 'binaryString', type: 'XLSM' }, - { name: 'doc.xlsb', isExcel: true, method: 'arrayBuffer', type: 'XLSB' }, - { name: 'data.csv', isExcel: false, method: 'binaryString', type: 'CSV' }, - { name: 'text.txt', isExcel: false, method: 'binaryString', type: 'CSV' } + { + name: "data.xlsx", + isExcel: true, + method: "binaryString", + type: "XLSX", + }, + { + name: "workbook.xls", + isExcel: true, + method: "arrayBuffer", + type: "XLS", + }, + { + name: "file.xlsm", + isExcel: true, + method: "binaryString", + type: "XLSM", + }, + { + name: "doc.xlsb", + isExcel: true, + method: "arrayBuffer", + type: "XLSB", + }, + { + name: "data.csv", + isExcel: false, + method: "binaryString", + type: "CSV", + }, + { + name: "text.txt", + isExcel: false, + method: "binaryString", + type: "CSV", + }, ]; - testFiles.forEach(testFile => { + testFiles.forEach((testFile) => { expect(FileUtils.isExcelFile(testFile.name)).toBe(testFile.isExcel); - expect(FileUtils.getExcelReadingMethod(testFile.name)).toBe(testFile.method); + expect(FileUtils.getExcelReadingMethod(testFile.name)).toBe( + testFile.method, + ); expect(FileUtils.getFileType(testFile.name)).toBe(testFile.type); }); }); }); }); - From 3567413def3c77f8a6c9f5cf79733b0fb6afc126 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 11:22:59 +0200 Subject: [PATCH 22/35] Add workflow --- .github/workflows/quality-check.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/quality-check.yml diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml new file mode 100644 index 0000000..b09e7df --- /dev/null +++ b/.github/workflows/quality-check.yml @@ -0,0 +1,27 @@ +name: Quality Check + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Check Prettier formatting + run: npm run format:check \ No newline at end of file From 78f73c69d07ad8392a1ee6c5c30340059205e5a6 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 11:26:25 +0200 Subject: [PATCH 23/35] Fix formatting in yaml --- .github/workflows/quality-check.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality-check.yml b/.github/workflows/quality-check.yml index b09e7df..a48c8d1 100644 --- a/.github/workflows/quality-check.yml +++ b/.github/workflows/quality-check.yml @@ -24,4 +24,4 @@ jobs: run: npm ci - name: Check Prettier formatting - run: npm run format:check \ No newline at end of file + run: npm run format:check diff --git a/package.json b/package.json index 9fb6329..8942ef2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,6 @@ "prettier": "^3.6.2" }, "lint-staged": { - "*.{js,json,md,html,css}": "prettier --write" + "*.{js,json,md,html,css,yml,yaml}": "prettier --write" } } From 49a4fe8df276d56e5c24ee9802145eb19f644ba1 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 17:50:49 +0200 Subject: [PATCH 24/35] Add e2e tests --- .github/workflows/e2e-tests.yml | 51 ++++++++ .gitignore | 5 + .prettierignore | 4 +- e2e/pages/ynab-converter.page.js | 200 ++++++++++++++++++++++++++++++ e2e/specs/column-mapping.spec.js | 167 +++++++++++++++++++++++++ e2e/specs/export-download.spec.js | 159 ++++++++++++++++++++++++ e2e/specs/file-upload.spec.js | 169 +++++++++++++++++++++++++ index.html | 61 +++++++-- package-lock.json | 65 ++++++++++ package.json | 8 ++ playwright.config.js | 38 ++++++ src/app.js | 5 + test_files/test.xls | Bin 0 -> 7168 bytes test_files/test.xlsb | Bin 0 -> 17990 bytes test_files/test.xlsm | Bin 0 -> 21390 bytes test_files/test.xlsx | Bin 0 -> 8326 bytes 16 files changed, 923 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 e2e/pages/ynab-converter.page.js create mode 100644 e2e/specs/column-mapping.spec.js create mode 100644 e2e/specs/export-download.spec.js create mode 100644 e2e/specs/file-upload.spec.js create mode 100644 playwright.config.js create mode 100644 test_files/test.xls create mode 100644 test_files/test.xlsb create mode 100644 test_files/test.xlsm create mode 100644 test_files/test.xlsx diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..537c7c9 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,51 @@ +name: E2E Tests + +on: + push: + branches: [main, gh-pages] + pull_request: + branches: [main, gh-pages] + merge_group: + +jobs: + e2e: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + name: E2E Tests - ${{ matrix.browser }} + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Run E2E tests + run: npx playwright test --project=${{ matrix.browser }} + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-${{ matrix.browser }} + path: playwright-report/ + retention-days: 30 + + - name: Upload test videos + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: test-videos-${{ matrix.browser }} + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 41b9f4c..b8800a0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ coverage/ *.xlsx *.xlsm *.xlsb + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/.prettierignore b/.prettierignore index e693c27..004c7fd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,6 @@ dist build tmp *.min.js -*.min.css \ No newline at end of file +*.min.css +playwright-report +test-results \ No newline at end of file diff --git a/e2e/pages/ynab-converter.page.js b/e2e/pages/ynab-converter.page.js new file mode 100644 index 0000000..1c3ad56 --- /dev/null +++ b/e2e/pages/ynab-converter.page.js @@ -0,0 +1,200 @@ +export class YnabConverterPage { + constructor(page) { + this.page = page; + + // File upload elements + this.fileInput = page.locator('[data-testid="file-input"]'); + this.dropzone = page.locator('[data-testid="dropzone"]'); + this.uploadWrapper = page.locator('[data-testid="upload-wrapper"]'); + + // Data display elements + this.toolWrapper = page.locator('[data-testid="tool-wrapper"]'); + this.dataTable = page.locator('[data-testid="data-preview-table"]'); + this.fileTypeBadge = page.locator('[data-testid="file-type-badge"]'); + + // Excel worksheet selector + this.worksheetSelector = page.locator('[data-testid="worksheet-select"]'); + + // Configuration elements + this.encodingSelect = page.locator('[data-testid="encoding-select"]'); + this.delimiterSelect = page.locator('[data-testid="delimiter-select"]'); + this.startRowInput = page.locator('[data-testid="start-row-input"]'); + this.extraRowCheckbox = page.locator('[data-testid="extra-row-checkbox"]'); + this.configDropdown = page.locator('[data-testid="config-dropdown"]'); + + // Column mapping selects + this.columnMappingSelects = page.locator('[data-testid^="column-select-"]'); + + // Profile elements + this.profileSelect = page.locator('[data-testid="profile-select"]'); + + // Action buttons + this.downloadButton = page.locator('[data-testid="download-button"]'); + this.invertFlowsButton = page.locator( + '[data-testid="invert-flows-button"]', + ); + this.toggleFormatButton = page.locator( + '[data-testid="toggle-format-button"]', + ); + this.reloadButton = page.locator('[data-testid="reload-button"]'); + } + + async goto() { + await this.page.goto("/"); + await this.page.waitForSelector(".show_on_load", { state: "visible" }); + } + + async uploadFile(filePath) { + await this.fileInput.setInputFiles(filePath); + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async dragAndDropFile(filePath) { + const dataTransfer = await this.page.evaluateHandle( + () => new DataTransfer(), + ); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragenter", { + dataTransfer, + }); + await this.page.dispatchEvent('[data-testid="dropzone"]', "dragover", { + dataTransfer, + }); + + await this.fileInput.setInputFiles(filePath); + const files = await this.fileInput.inputValue(); + + await this.page.dispatchEvent('[data-testid="dropzone"]', "drop", { + dataTransfer: await this.page.evaluateHandle((files) => { + const dt = new DataTransfer(); + // Simulate file drop + return dt; + }, files), + }); + + await this.toolWrapper.waitFor({ state: "visible" }); + } + + async selectWorksheet(index) { + await this.worksheetSelector.selectOption({ index: index.toString() }); + } + + async setColumnMapping(ynabColumn, csvColumn) { + const select = this.page.locator( + `[data-testid="column-select-${ynabColumn}"]`, + ); + + // Wait for the select to have options available and find the option value + const optionValue = await this.page.evaluate( + ({ selectId, targetColumn }) => { + const select = document.querySelector(`[data-testid="${selectId}"]`); + if (!select) return false; + const options = Array.from(select.options); + + // Try to find option that matches the target column + const matchingOption = options.find( + (option) => + option.textContent.includes(targetColumn) || + option.value.includes(targetColumn) || + option.label === targetColumn || + option.label.startsWith(targetColumn), // Handle "Transaction Date (1)" format + ); + + return matchingOption ? matchingOption.value : false; + }, + { selectId: `column-select-${ynabColumn}`, targetColumn: csvColumn }, + ); + + if (optionValue) { + await select.selectOption(optionValue); + + // Wait for AngularJS to process the change and update the preview + await this.page.waitForFunction( + () => { + // Check if Angular scope exists and has processed the change + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + // Small additional wait to ensure DOM updates are complete + await this.page.waitForTimeout(100); + } else { + throw new Error( + `Could not find option for column: ${csvColumn} in ${ynabColumn} select`, + ); + } + } + + async getPreviewData() { + // Use data-testid selectors for reliable element targeting + const rows = await this.page + .locator( + '[data-testid="data-preview-table"] [data-testid^="preview-row-"]', + ) + .all(); + const data = []; + + for (const row of rows) { + const cells = await row + .locator('[data-testid^="preview-cell-"]') + .allTextContents(); + data.push(cells); + } + + return data; + } + + async downloadConvertedFile() { + // Ensure data is processed and download button is available + await this.downloadButton.waitFor({ state: "visible" }); + + // Wait for any pending Angular operations to complete + await this.page.waitForFunction( + () => { + const scope = angular.element(document.body).scope(); + return scope && scope.preview && scope.preview.length > 0; + }, + { timeout: 5000 }, + ); + + const downloadPromise = this.page.waitForEvent("download"); + await this.downloadButton.click(); + const download = await downloadPromise; + return download; + } + + async openConfigDropdown() { + await this.configDropdown.click(); + await this.page.locator(".dropdown-menu").waitFor({ state: "visible" }); + } + + async setEncoding(encoding) { + await this.openConfigDropdown(); + await this.encodingSelect.selectOption(encoding); + } + + async setDelimiter(delimiter) { + await this.openConfigDropdown(); + await this.delimiterSelect.selectOption(delimiter); + } + + async setStartRow(row) { + await this.openConfigDropdown(); + await this.startRowInput.fill(row.toString()); + } + + async toggleExtraRow() { + await this.openConfigDropdown(); + await this.extraRowCheckbox.click(); + } + + async getCurrentProfile() { + return await this.profileSelect.inputValue(); + } + + async selectProfile(profileName) { + await this.profileSelect.selectOption(profileName); + } +} diff --git a/e2e/specs/column-mapping.spec.js b/e2e/specs/column-mapping.spec.js new file mode 100644 index 0000000..23714df --- /dev/null +++ b/e2e/specs/column-mapping.spec.js @@ -0,0 +1,167 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("Column Mapping", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Upload a test CSV file + const csvContent = `Transaction Date,Description,Debit,Credit,Balance +2024-01-01,Grocery Store,50.00,,1000.00 +2024-01-02,Salary,,2000.00,3000.00 +2024-01-03,Electric Bill,150.00,,2850.00`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-mapping.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("displays column mapping dropdowns", async ({ page }) => { + const mappingSelects = await ynabPage.columnMappingSelects.count(); + expect(mappingSelects).toBeGreaterThan(0); + + // Check for YNAB column headers using test IDs + await expect( + page.locator('[data-testid="column-header-Date"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Payee"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Memo"]'), + ).toBeVisible(); + }); + + test("allows mapping CSV columns to YNAB format", async ({ page }) => { + // Map Date column + await ynabPage.setColumnMapping("Date", "Transaction Date"); + + // Map Payee column + await ynabPage.setColumnMapping("Payee", "Description"); + + // Verify preview updates + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // First row should have date from Transaction Date column + expect(previewData[0][0]).toContain("2024-01-01"); + }); + + test("toggles between old and new YNAB format", async ({ page }) => { + // Check initial format using test IDs + const outflowExists = await page + .locator('[data-testid="column-header-Outflow"]') + .isVisible(); + const inflowExists = await page + .locator('[data-testid="column-header-Inflow"]') + .isVisible(); + + if (outflowExists && inflowExists) { + // Toggle to new format + await ynabPage.toggleFormatButton.click(); + + // Should now show Amount column + await expect( + page.locator('[data-testid="column-header-Amount"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).not.toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).not.toBeVisible(); + + // Toggle back + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } else { + // Started with new format, toggle to old + await ynabPage.toggleFormatButton.click(); + await expect( + page.locator('[data-testid="column-header-Outflow"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="column-header-Inflow"]'), + ).toBeVisible(); + } + }); + + test("inverts transaction flows", async ({ page }) => { + // Map columns first + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + // Map both Outflow and Inflow to the same field (Balance) to enable invert flows button + await ynabPage.setColumnMapping("Outflow", "Balance"); + await ynabPage.setColumnMapping("Inflow", "Balance"); + + // Get initial preview data + const initialData = await ynabPage.getPreviewData(); + + // Click invert flows button (should now be visible) + await ynabPage.invertFlowsButton.click(); + + // Get updated preview data + const invertedData = await ynabPage.getPreviewData(); + + // Data should be different after inversion + expect(invertedData).not.toEqual(initialData); + }); + + test("preserves column mappings when switching formats", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Transaction Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // Get the selected values using test IDs + const dateMapping = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMapping = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + // Toggle format + await ynabPage.toggleFormatButton.click(); + + // Check that Date and Payee mappings are preserved + const dateMappingAfter = await page + .locator('[data-testid="column-select-Date"]') + .inputValue(); + const payeeMappingAfter = await page + .locator('[data-testid="column-select-Payee"]') + .inputValue(); + + expect(dateMappingAfter).toBe(dateMapping); + expect(payeeMappingAfter).toBe(payeeMapping); + }); + + test("handles empty column mapping", async ({ page }) => { + // Leave some columns unmapped + await ynabPage.setColumnMapping("Date", "Transaction Date"); + // Leave Payee unmapped + + // Should still show preview + const previewData = await ynabPage.getPreviewData(); + expect(previewData.length).toBeGreaterThan(0); + + // Unmapped columns should be empty (dots may be visual placeholders via CSS) + expect(previewData[0][1].trim()).toBe(""); // Payee should be empty + }); +}); diff --git a/e2e/specs/export-download.spec.js b/e2e/specs/export-download.spec.js new file mode 100644 index 0000000..08295fe --- /dev/null +++ b/e2e/specs/export-download.spec.js @@ -0,0 +1,159 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; +import * as fs from "fs"; +import * as path from "path"; + +test.describe("Export and Download", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + + // Upload a test CSV file + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction 1,-50.00 +2024-01-02,Test Transaction 2,100.00 +2024-01-03,Test Transaction 3,-25.50`; + + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test-export.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + }); + + test("downloads converted YNAB file", async ({ page }) => { + // Map columns + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + // For new format with Amount column + const amountColumnExists = + (await page.locator('[data-testid="column-select-Amount"]').count()) > 0; + if (amountColumnExists) { + await ynabPage.setColumnMapping("Amount", "Amount"); + } else { + // Old format - need to handle Outflow/Inflow + // This would require more complex logic to split positive/negative + } + + // Download the file + const download = await ynabPage.downloadConvertedFile(); + + // Verify download + expect(download).toBeTruthy(); + + // Check filename format (should be ynab_data_YYYYMMDD.csv) + const filename = download.suggestedFilename(); + expect(filename).toMatch(/^ynab_data_\d{8}\.csv$/); + + // Save and verify content + const downloadPath = await download.path(); + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // Should be valid CSV + expect(content).toContain(","); + + // Should contain our data + expect(content).toContain("Test Transaction 1"); + expect(content).toContain("Test Transaction 2"); + } + }); + + test("download button appears after file upload", async ({ page }) => { + await expect(ynabPage.downloadButton).toBeVisible(); + }); + + test("generates correct filename with current date", async ({ page }) => { + const download = await ynabPage.downloadConvertedFile(); + const filename = download.suggestedFilename(); + + // Get current date in YYYYMMDD format + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, "0"); + const day = String(today.getDate()).padStart(2, "0"); + const expectedDate = `${year}${month}${day}`; + + expect(filename).toBe(`ynab_data_${expectedDate}.csv`); + }); + + test("exported file contains proper CSV format", async ({ page }) => { + // Set up mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + const lines = content.trim().split("\n"); + + // Should have header row + expect(lines.length).toBeGreaterThan(1); + + // Check header contains YNAB columns + const header = lines[0]; + expect(header).toContain("Date"); + expect(header).toContain("Payee"); + + // Check data rows + expect(lines.length).toBe(4); // Header + 3 data rows + } + }); + + test("handles Excel file export", async ({ page }) => { + // Upload an Excel file instead + await page.reload(); + await ynabPage.goto(); + + await ynabPage.uploadFile("test_files/test.xlsx"); + await ynabPage.toolWrapper.waitFor({ state: "visible" }); + + // Download should work the same way + const download = await ynabPage.downloadConvertedFile(); + expect(download).toBeTruthy(); + + // Should still export as CSV + const filename = download.suggestedFilename(); + expect(filename).toMatch(/\.csv$/); + }); + + test("exported data respects column mappings", async ({ page }) => { + // Create specific mappings + await ynabPage.setColumnMapping("Date", "Date"); + await ynabPage.setColumnMapping("Payee", "Description"); + await ynabPage.setColumnMapping("Memo", "Amount"); // Intentionally wrong mapping + + const download = await ynabPage.downloadConvertedFile(); + const downloadPath = await download.path(); + + if (downloadPath) { + const content = fs.readFileSync(downloadPath, "utf-8"); + + // The Amount values should appear in Memo column + expect(content).toContain("-50.00"); + expect(content).toContain("100.00"); + + // Parse CSV to verify structure + const lines = content.trim().split("\n"); + const dataRow = lines[1].split(","); + + // Memo column (index 2) should have amount value + expect(dataRow[2]).toMatch(/[\-\d\.]+/); + } + }); +}); diff --git a/e2e/specs/file-upload.spec.js b/e2e/specs/file-upload.spec.js new file mode 100644 index 0000000..180c390 --- /dev/null +++ b/e2e/specs/file-upload.spec.js @@ -0,0 +1,169 @@ +import { test, expect } from "@playwright/test"; +import { YnabConverterPage } from "../pages/ynab-converter.page"; + +test.describe("File Upload", () => { + let ynabPage; + + test.beforeEach(async ({ page }) => { + ynabPage = new YnabConverterPage(page); + await ynabPage.goto(); + + // Wait for FileUtils to be loaded by the page + await page.waitForFunction(() => window.FileUtils !== undefined); + }); + + test("shows upload interface on load", async ({ page }) => { + await expect(ynabPage.uploadWrapper).toBeVisible(); + await expect(ynabPage.dropzone).toBeVisible(); + await expect(page.getByText("Drop CSV or Excel file")).toBeVisible(); + }); + + test("accepts CSV file upload", async ({ page }) => { + // Create a test CSV file content + const csvContent = `Date,Description,Amount +2024-01-01,Test Transaction,-50.00 +2024-01-02,Another Transaction,100.00`; + + // Create a file from string + await page.evaluate((content) => { + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }, csvContent); + + await expect(ynabPage.toolWrapper).toBeVisible(); + await expect(ynabPage.dataTable).toBeVisible(); + }); + + // Test each Excel format + const excelFormats = [ + { ext: "xlsx", name: "Excel 2007+" }, + { ext: "xls", name: "Excel 97-2003" }, + { ext: "xlsm", name: "Excel with Macros" }, + { ext: "xlsb", name: "Binary Excel" }, + ]; + + for (const format of excelFormats) { + test(`uploads ${format.name} (${format.ext}) file successfully`, async ({ + page, + }) => { + const filePath = `test_files/test.${format.ext}`; + + await ynabPage.uploadFile(filePath); + + // Verify file was loaded + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 15000 }); + await expect(ynabPage.dataTable).toBeVisible(); + + // Verify file type badge shows correct format (wait longer for Excel processing) + await expect(ynabPage.fileTypeBadge).toBeVisible({ timeout: 15000 }); + const badgeText = await ynabPage.fileTypeBadge.textContent(); + expect(badgeText.toLowerCase()).toContain(format.ext); + }); + } + + test("shows worksheet selector for multi-sheet Excel files", async ({ + page, + }) => { + // Would need a multi-sheet test file + // For now, verify selector doesn't show for single-sheet files + await ynabPage.uploadFile("test_files/test.xlsx"); + + // Check if worksheet selector exists + const worksheetSelectorCount = await ynabPage.worksheetSelector.count(); + + // This test assumes test.xlsx has only one sheet + // Update when we have a multi-sheet test file + if (worksheetSelectorCount > 0) { + const worksheetOptions = await ynabPage.worksheetSelector + .locator("option") + .count(); + expect(worksheetOptions).toBeGreaterThan(0); + } + }); + + test("handles drag and drop file upload", async ({ page }) => { + // Create a test CSV for drag and drop + const csvContent = `Date,Payee,Amount +2024-01-01,Store A,-25.00 +2024-01-02,Store B,-30.00`; + + // Simulate drag and drop using test ID + await page.evaluate((content) => { + const dropzone = document.querySelector('[data-testid="dropzone"]'); + + // Create drag events + const dragEnterEvent = new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + }); + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true, + }); + + dropzone.dispatchEvent(dragEnterEvent); + dropzone.dispatchEvent(dragOverEvent); + + // Create file and drop event + const blob = new Blob([content], { type: "text/csv" }); + const file = new File([blob], "drag-test.csv", { type: "text/csv" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer: dt, + }); + + dropzone.dispatchEvent(dropEvent); + }, csvContent); + + // Verify file was processed + await expect(ynabPage.toolWrapper).toBeVisible({ timeout: 5000 }); + }); + + test("shows error for invalid file types", async ({ page }) => { + // Create an invalid file type + await page.evaluate(() => { + const blob = new Blob(["invalid content"], { type: "text/plain" }); + const file = new File([blob], "test.txt", { type: "text/plain" }); + const dt = new DataTransfer(); + dt.items.add(file); + + const fileInput = document.querySelector('[data-testid="file-input"]'); + fileInput.files = dt.files; + fileInput.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Wait a moment for the app to process the file + await page.waitForTimeout(1000); + + // The app should either: + // 1. Show the tool wrapper (if it parsed as CSV) + // 2. Stay on upload screen (if it failed) + // 3. Show some error state + const toolWrapperVisible = await ynabPage.toolWrapper.isVisible(); + const uploadWrapperVisible = await ynabPage.uploadWrapper.isVisible(); + + // At least one wrapper should be visible (upload or tool) + // If both are hidden, that indicates an unexpected error state + expect(toolWrapperVisible || uploadWrapperVisible).toBeTruthy(); + }); + + test("configuration dropdown is accessible", async ({ page }) => { + await ynabPage.openConfigDropdown(); + + await expect(ynabPage.encodingSelect).toBeVisible(); + await expect(ynabPage.delimiterSelect).toBeVisible(); + await expect(ynabPage.startRowInput).toBeVisible(); + await expect(ynabPage.extraRowCheckbox).toBeVisible(); + }); +}); diff --git a/index.html b/index.html index 1fedb0b..df1783d 100644 --- a/index.html +++ b/index.html @@ -68,10 +68,18 @@
-
+
Load a different file
-
+
Save YNAB Data
@@ -91,10 +99,15 @@ filename="filename" encoding="{{file.chosenEncoding}}" delimiter="{{file.chosenDelimiter}}" + data-testid="dropzone" >

-
+
Drop CSV or Excel file
@@ -107,6 +120,7 @@ filename="filename" encoding="{{file.chosenEncoding}}" delimiter="{{file.chosenDelimiter}}" + data-testid="file-input" />
@@ -116,6 +130,7 @@ data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" + data-testid="config-dropdown" > Input file config @@ -130,6 +145,7 @@ ng-click="$event.stopPropagation()" ng-change="encodingChosen(file.chosenEncoding)" class="form-control" + data-testid="encoding-select" >
@@ -141,6 +157,7 @@ ng-click="$event.stopPropagation()" ng-change="delimiterChosen(file.chosenDelimiter)" class="form-control" + data-testid="delimiter-select" >
@@ -152,6 +169,7 @@ ng-model="file.startAtRow" ng-change="startRowSet(file.startAtRow)" class="form-control" + data-testid="start-row-input" />
@@ -162,6 +180,7 @@ ng-change="extraRowSet(file.extraRow)" class="form-check-input" value="" + data-testid="extra-row-checkbox" />
-
+
Toggle column format

YNAB Data (first 10 rows) - +

-

+
- - + +
@@ -265,8 +302,16 @@

{{row[col]}}
+ {{row[col]}} +
diff --git a/package-lock.json b/package-lock.json index 0b1cc25..6221fe4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,14 @@ "": { "name": "ynab-csv", "version": "2.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "http-server": "^14.1.1" }, "devDependencies": { "@babel/preset-env": "^7.28.0", + "@playwright/test": "^1.54.1", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", "husky": "^9.1.7", @@ -2602,6 +2604,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.38", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", @@ -7078,6 +7096,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.37.tgz", diff --git a/package.json b/package.json index 8942ef2..3a373cd 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,13 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:ui": "playwright test --ui", + "test:e2e:chromium": "playwright test --project=chromium", + "test:e2e:firefox": "playwright test --project=firefox", + "test:e2e:webkit": "playwright test --project=webkit", "start": "http-server --port 3000 -o", "bs": "browser-sync . -w", "format": "prettier --write .", @@ -33,6 +40,7 @@ }, "devDependencies": { "@babel/preset-env": "^7.28.0", + "@playwright/test": "^1.54.1", "babel-jest": "^30.0.5", "browser-sync": "^3.0.2", "husky": "^9.1.7", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..803251c --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ], + + webServer: { + command: "npm start", + port: 3000, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/src/app.js b/src/app.js index f36c7fc..2581367 100644 --- a/src/app.js +++ b/src/app.js @@ -236,6 +236,10 @@ angular.element(document).ready(function () { }; $scope.setInitialScopeState(); + + // Make FileUtils available in templates + $scope.FileUtils = FileUtils; + $scope.profileChosen = function (profileName) { $location.search("profile", profileName); $scope.profile = $scope.profiles[$scope.profileName]; @@ -277,6 +281,7 @@ angular.element(document).ready(function () { try { // Store filename for later use in worksheet switching $scope.currentFilename = newValue.filename; + $scope.filename = newValue.filename; // Process file based on type if ($scope.data_object.isExcelFile(newValue.filename)) { diff --git a/test_files/test.xls b/test_files/test.xls new file mode 100644 index 0000000000000000000000000000000000000000..10ffef946d633e248f7c11323693a1e252cc4fa8 GIT binary patch literal 7168 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 Nk$ncHY|qix{{h`wHPZk9 literal 0 HcmV?d00001 diff --git a/test_files/test.xlsm b/test_files/test.xlsm new file mode 100644 index 0000000000000000000000000000000000000000..0cdeea1b17db1af31c5d56a3ba407016d8770b57 GIT binary patch literal 21390 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 literal 0 HcmV?d00001 diff --git a/test_files/test.xlsx b/test_files/test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0ec90e9557cd8895610cfdcf1d54c7529a5c330b GIT binary patch literal 8326 zcmb`NbzD?i)b|G|0f7Mq7^G8LN?-u#PH9PLX@sFu=`QK+lo~=MrAt5=q(cy-yF+;9 zdhheR;=TU&p3j^!`<&V9cRp+FbG|#)JW)VK0RaGjy8unD3*CZr%{McMGr>Cm02TlQ zc;;ko3w_A`*AWQ-MEr1jtB7y6?&P=^yz7Jc%`3kyfK&RlVNcFmu154dTYAngHrjH% zlN0WM8dP}_TN$~Vn|<=SnGs{Ps=g_8+oPhg`VPzf^GUvo^P_jI@JrWvua17%u;upR zH}Vg@OXJe4Q&7zy1@sfrN3M+Gq0^c#Q^eQguj4hi_+VYV&YeM3C)dwE|H`YIIZtpM zg^kqBPm9LNSxJ!08_}!(HP|abS`gL?8_o_Zi+!X|G<|It+BmG|P346HUWq3j_WzvQ zRB5KxXyJUAEj`%v_M=cc>He8GkMH{rOX$;I(4X9j3kvl(VI#tKQX~KX4-pqL2h%4` z4lke&O&y%f+1>4I|0_Cn(MmRfqCm;rYs}Q9274+Lo#P~RqC0X%RT49wjWcnJ1WEjS z8DJ?2(NZm*V;-|xy1wdW-wdjz8&&BcN3R{BRl@Vxlc zP?SZ-spHc)+f|MD=YzI@7D!*zkXT{D#!)r&Tor3#aB#-Al`C3GkPcO1ANjWPr_l)y zBN5I|Aa>xd&KtjY@h9IihlembIDjZ$y|QVR=ZidnOh4X(MkvZo<87y3PGR@!Zp@J4 zc>`iPIVj;cO@5)+CQgBwLK^j;muZomg|FYPt7WZxBHn(a_OcHrz7JV3Q$JNAUt^Tz z45InIXZC5h?{~6qd40OY#&kc9UcQNT@lmVh&OuTSreth0soifYq5g56yaQ)3Q#tM? zz3q*=DkOCe;Yk6aDRB|rxZ6H-b8xaTad5EtlQU5&gZ7N3L(@6n>X+l|3&aEv*qDOK$B#JNO)HBamnu5I?l6VShn;OdAb?27QUXpS;dJj;`e z-Tyjwc%7U1#Dz9|MO(Qy4RVJkn(EVCKt1X(qp6~UipCXYzD~4WQ}#AfMeTBlkHg zPViM+DO@hyy^-5$;dVS*1u}7z}NRIDB__a1sLN+x;B&^czSIU+EkpZOVrFfS`9V z*H-$Zaw)PwV3UU zR=0aEL)q;sMd&M0BuT3Ru-M09NJdzCHkXExk4<~v08p?KzK?L0M~^GA2B^ujDL-Pr zn75Vlx;lXJ{p#z*k|4@)_qGQ_tMJ^3j>|{FYwa=KH+ez0)>kFyyI#-jv8u#u;y)?v zVy3NuWi#SNTK3MHk*ZXc1Ngg-HMVk8!&}X5*)NZ$@*8a|3;PmWd+hB*(Rz zy9e$Gw69@^_MP6zkl}>~UVEckrz>*{2xzTWKIz7wo>nU)I- z7ECG?BZBKk_pT2#0CoBgg+bjY+4B)G7DC(469xC&q*}~rp6h4Owz5}_8hqK8vAJpa zpzX*%jFOS;$(ix)CXaadTVc0jq(n`AVdX%pN$mXmw}4FHk)-nThZ#pdi0!7QV{bCL z7SVmeOa*>$6644jVnYpYB3r2Vd6ZUU->@Ak#0wi!w|}>6>#tni&|{F%HL+;R1W8%k zPaa*BGj*~q$#(HqM62@K;q`w!i?`MO)y1Fus7qjy(z>`wDzk?zqtYd-6t===b*Vp3 zWiR)+1FFMA+hH611H|lhWt&7o=nv=lSS;C`n zQU5XT5>>rUt**wMVwfP+=OG?3R#$9ZiYF0~U*9v%{sIvl9ax1+55>hap`q~bfZ@!= zaKggXE}O|@L*H&fIep3+ssUl99I2xadF!BAA|6qFdcl~fTRiB(ZR6Y z#9_Q#pF9!bO~&^QrFqXFBKl$v3Jur^G*u%o#q0u@fK3z+%VuM(Jv6C~FnhJ|=Mq)f zTJO5gYJoSY%x#s@aYWaP&v%Ap&jN)<4@j3TDZ;MlUrp_IullJjCwn>ZS4jGbfScg* z%MZPhhO(ZAUFFg4!xiPO_G{>TW!Y~om+ve=_U?2+sn9}KMehMeUV*8ykU2$#&`n;t z<8%S`$wHBqAVB1MY-hxj9lKTaeHcWK+^=H;<{IfC70va|2cK4W|2ykK|3 zR_GA6=89z4f^3@nZt&}_zNlg%lT;NKDCc7gE4b#ouDc zzvsm7ll3+oq9N#W3e6^Ypq0Qh@|=MrN-1E4rB(TG`{UK2-Vp=G!q79Nk>)ei9WX&~ z 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 F{{Y!;foA{! literal 0 HcmV?d00001 From d9ba002d14a383e3c9e4c47b0f7b1e378140da48 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sat, 26 Jul 2025 17:55:40 +0200 Subject: [PATCH 25/35] Jest ignore playwright --- jest.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/jest.config.js b/jest.config.js index 9d971bd..eaf4511 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,7 @@ module.exports = { }, moduleFileExtensions: ["js"], testMatch: ["**/__tests__/**/*.js", "**/?(*.)+(spec|test).js"], + testPathIgnorePatterns: ["/e2e/"], collectCoverageFrom: ["src/**/*.js", "!src/**/*.min.js"], setupFilesAfterEnv: ["/jest.setup.js"], }; From c7f334997261c3ddf970417afbcede8eaa3b2f92 Mon Sep 17 00:00:00 2001 From: Stephen Lau Date: Sun, 27 Jul 2025 06:33:55 +0200 Subject: [PATCH 26/35] Remove google tag --- index.html | 5 ----- 1 file changed, 5 deletions(-) diff --git a/index.html b/index.html index df1783d..3d07cd2 100644 --- a/index.html +++ b/index.html @@ -46,11 +46,6 @@ src="https://js.sentry-cdn.com/9984f4ed07f54d7ca6107525f0d990a1.min.js" crossorigin="anonymous" > - - +