diff --git a/.clasp.json b/.clasp.json index 8e02522..fb07d72 100644 --- a/.clasp.json +++ b/.clasp.json @@ -1,4 +1,4 @@ { - "scriptId":"1viSsQkJME8IWYt6v-LplS1i-06k4qpZ5Qb9hUuxpofPHUaFtJuQbOo-T", + "scriptId": "1viSsQkJME8IWYt6v-LplS1i-06k4qpZ5Qb9hUuxpofPHUaFtJuQbOo-T", "rootDir": "src/" } diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..c6a1376 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "trailingComma": "es5", + "singleQuote": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8763947 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[typescript]": { + "editor.formatOnSave": true + } +} diff --git a/jsconfig.json b/jsconfig.json index 6e2b1b5..f647b80 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,6 +1,6 @@ -{ +{ "compilerOptions": { "checkJs": true, "lib": ["es6"] - }, + } } diff --git a/src/app.ts b/src/app.ts index f7f70c8..dac985d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,24 +1,21 @@ -const ui = SpreadsheetApp.getUi() +const ui = SpreadsheetApp.getUi(); function onOpen(): void { - ui.createMenu('Import') - .addItem('Upload CSV', 'fileUploadDialog') - .addToUi() + ui.createMenu('Import').addItem('Upload CSV', 'fileUploadDialog').addToUi(); } function fileUploadDialog(): void { const html = HtmlService.createTemplateFromFile('fileinput.html') .evaluate() .setWidth(900) - .setHeight(600) - SpreadsheetApp.getUi() - .showModalDialog(html, 'File upload dialog'); + .setHeight(600); + SpreadsheetApp.getUi().showModalDialog(html, 'File upload dialog'); } function include(filename: string): string { - return HtmlService.createHtmlOutputFromFile(filename).getContent() + return HtmlService.createHtmlOutputFromFile(filename).getContent(); } function getStrategyOptions() { - return StrategyOption + return StrategyOption; } diff --git a/src/appsscript.json b/src/appsscript.json index a783b77..3e549a8 100644 --- a/src/appsscript.json +++ b/src/appsscript.json @@ -1,14 +1,15 @@ { "timeZone": "Europe/Amsterdam", - "dependencies": { - }, + "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "sheets": { - "macros": [{ - "menuName": "Delete duplicate transaction", - "functionName": "macroDeleteDuplicateTransaction", - "defaultShortcut": "Ctrl+Alt+Shift+5" - }] + "macros": [ + { + "menuName": "Delete duplicate transaction", + "functionName": "macroDeleteDuplicateTransaction", + "defaultShortcut": "Ctrl+Alt+Shift+5" + } + ] } -} \ No newline at end of file +} diff --git a/src/config.ts b/src/config.ts index f828462..17c0b95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,12 +1,11 @@ - const defaultAfterImport = [ - (table: Table) => Utils.autoFillColumns(table, AUTO_FILL_COLUMNS) -] + (table: Table) => Utils.autoFillColumns(table, AUTO_FILL_COLUMNS), +]; class Config { static getConfig(): Strategy { return { - 'n26': { + n26: { beforeImport: [ Utils.deleteLastRow, Utils.deleteFirstRow, @@ -26,11 +25,11 @@ class Config { }, afterImport: defaultAfterImport, }, - 'rabobank': { + rabobank: { beforeImport: [ Utils.deleteLastRow, Utils.deleteFirstRow, - Utils.sortByDate(raboCols.Datum) + Utils.sortByDate(raboCols.Datum), ], columnImportRules: { ref: buildColumn(raboCols.Volgnr, parseInt), @@ -44,41 +43,57 @@ class Config { description: buildColumn(raboCols.Omschrijving1, String), label: buildColumn(raboCols.Omschrijving2, String), }, - afterImport: defaultAfterImport + afterImport: defaultAfterImport, }, - "bbva": { + bbva: { beforeImport: [ Utils.deleteLastRow, Utils.deleteFirstRow, - Utils.sortByDate(bbvaCols.Date) + Utils.sortByDate(bbvaCols.Date), ], columnImportRules: { ref: null, iban: (data) => new Array(data.length).fill(BankAccount.BBVA), - date: buildColumn(bbvaCols.Date, (val) => new Date(val)), - amount: buildColumn(bbvaCols.Amount, parseFloat), + date: buildColumn(bbvaCols.Date, (val) => Utils.transformDate(val)), + amount: buildColumn(bbvaCols.Amount, Utils.transformMoneyColumn), category: null, contra_iban: null, currency: buildColumn(bbvaCols.Currency, String), description: buildColumn(bbvaCols.Comments, String), - label: buildColumn(bbvaCols.SubjectLine, String) + label: buildColumn(bbvaCols.SubjectLine, String), }, - afterImport: defaultAfterImport + afterImport: defaultAfterImport, }, - "openbank": { + openbank: { + beforeImport: [ + Utils.deleteFirstRow, + Utils.deleteLastRow, + // open bank has some empty columns when importing + (table) => Utils.deleteColumns(table, [0, 2, 4, 6, 8]), + ], columnImportRules: { ref: null, iban: (data) => new Array(data.length).fill(BankAccount.OPENBANK), - date: null, - amount: null, + date: buildColumn(openbankCols.Fecha, (val) => { + let [day, month, year] = val.split('/'); + let yearNum = +year; + if (year && year.length === 2) { + // if year is of length 2 it means it only provides the year since 2000 + // to fix we add 2000 + yearNum = +year + 2000; + } + return new Date(+yearNum, +month - 1, +day); + }), + amount: buildColumn(openbankCols.Importe, Utils.transformMoneyColumn), category: null, contra_account: null, label: null, - description: null, + description: buildColumn(openbankCols.Concepto, String), contra_iban: null, currency: null, - } - } - } + }, + afterImport: defaultAfterImport, + }, + }; } -} \ No newline at end of file +} diff --git a/src/csv-processor.ts b/src/csv-processor.ts index 89cf158..f7f18da 100644 --- a/src/csv-processor.ts +++ b/src/csv-processor.ts @@ -1,18 +1,18 @@ -const sourceSheetId = 1093484485 +const sourceSheetId = 1093484485; const AUTO_FILL_COLUMNS = [ 5, // balance column 9, // category icon 12, // hours column 14, // disabled column -] +]; -const FireSpreadsheet = SpreadsheetApp.getActiveSpreadsheet() -const sheets = FireSpreadsheet.getSheets() -const Props = PropertiesService.getUserProperties() -const sourceSheet = getSheetById(sourceSheetId) +const FireSpreadsheet = SpreadsheetApp.getActiveSpreadsheet(); +const sheets = FireSpreadsheet.getSheets(); +const Props = PropertiesService.getUserProperties(); +const sourceSheet = getSheetById(sourceSheetId); function getSheetById(id: number): GoogleAppsScript.Spreadsheet.Sheet { - return sheets.find(sheet => sheet.getSheetId() === id) + return sheets.find((sheet) => sheet.getSheetId() === id); } const FireColumns = [ @@ -31,73 +31,82 @@ const FireColumns = [ 'contra_iban', 'disabled', 'currency', -] +]; function buildColumn( column: InputColumn, transformer: (value: string) => T -): (data: Table) => T[] -{ +): (data: Table) => T[] { return (data: Table): T[] => { - const rowCount = data.length - const columnTable = Utils.transpose(data) // try to transpose somewhere else + const rowCount = data.length; + const columnTable = Utils.transpose(data); // try to transpose somewhere else if (columnTable[column] !== undefined) { - return columnTable[column].map((val) => transformer(val)) + return columnTable[column].map((val) => transformer(val)); } else { - return new Array(rowCount) + return new Array(rowCount); } - } + }; } function buildNewTableData(input: Table, columnImportRules: FireColumnRules) { - let output: Table = [] - const rowCount = input.length + let output: Table = []; + const rowCount = input.length; for (const columnName of FireColumns) { if (!(columnName in columnImportRules) || !columnImportRules[columnName]) { - output.push(new Array(rowCount)) - continue + output.push(new Array(rowCount)); + continue; } - const colRule = columnImportRules[columnName] as ColumnRule - let column: any[] + const colRule = columnImportRules[columnName] as ColumnRule; + let column: any[]; try { - column = colRule(input) - column = Utils.ensureLength(column, rowCount) - } catch(e) { - Logger.log(e) - column = new Array(rowCount) + column = colRule(input); + column = Utils.ensureLength(column, rowCount); + } catch (e) { + Logger.log(e); + column = new Array(rowCount); } - output.push(column) + output.push(column); } - output = Utils.transpose(output) // flip columns to rows - return output + output = Utils.transpose(output); // flip columns to rows + return output; } /** - * This function gets called by client side script + * This function gets called by client side script * @see file-input.html */ -function processCSV(input: Table, importStrategy: StrategyOption): ServerResponse { - const strategies = Config.getConfig() - sourceSheet.activate() - sourceSheet.showSheet() +function processCSV( + input: Table, + importStrategy: StrategyOption +): ServerResponse { + const strategies = Config.getConfig(); + sourceSheet.activate(); + sourceSheet.showSheet(); if (!(importStrategy in strategies)) { - throw new Error(`Import strategy ${importStrategy} is not defined!`) + throw new Error(`Import strategy ${importStrategy} is not defined!`); } - const { beforeImport, columnImportRules, afterImport } = strategies[importStrategy] + const { beforeImport, columnImportRules, afterImport } = + strategies[importStrategy]; - for (const rule of beforeImport) { - input = rule(input) + if (beforeImport) { + for (const rule of beforeImport) { + input = rule(input); + } } - let output = buildNewTableData(input, columnImportRules) - Utils.importData(output) - for (const rule of afterImport) { - rule(output) + + let output = buildNewTableData(input, columnImportRules); + Utils.importData(output); + + if (afterImport) { + for (const rule of afterImport) { + rule(output); + } } - const msg = `imported ${output.length} rows!` - Logger.log(`processCSV done: ${msg}`) + const msg = `imported ${output.length} rows!`; + Logger.log(`processCSV done: ${msg}`); return { message: msg, - } + }; } diff --git a/src/fileinput.html b/src/fileinput.html index b217c07..a7b69b0 100644 --- a/src/fileinput.html +++ b/src/fileinput.html @@ -1,130 +1,148 @@ - - - - - - - - - - -
-
-
-
-
- Select File - -
-
- + + + + + + + + + + +
+
+
+
+ Select File + +
+
+ +
+ +
+ + +
-
- - +
+
+ +
-
+
-
- -
+
Preview data
+
- -
-
Preview data
-
-
- -
-

-
- - - - - - + + + + - - \ No newline at end of file + function handleFormSubmit() { + const file = getFile(); + if (!file || !isAllowedFile(file.type)) return; + const importStrategy = document.getElementById('import-strategy')?.value; + Papa.parse(file, { + complete: (result) => submitDataToServer(result.data, importStrategy), + error: onParseError, + }); + } + + diff --git a/src/jsonimport.ts b/src/jsonimport.ts index acaf5d4..da3d2af 100644 --- a/src/jsonimport.ts +++ b/src/jsonimport.ts @@ -1,37 +1,36 @@ /** -* Imports JSON data to your spreadsheet -* @param url URL of your JSON data as string -* @param xpath simplified xpath as string -* @customfunction -*/ + * Imports JSON data to your spreadsheet + * @param url URL of your JSON data as string + * @param xpath simplified xpath as string + * @customfunction + */ function IMPORTJSON(url: string, xpath: string) { - try{ + try { // /rates/EUR var res = UrlFetchApp.fetch(url); var content = res.getContentText(); var json = JSON.parse(content); - - var patharray = xpath.split("."); + + var patharray = xpath.split('.'); //Logger.log(patharray); - - for(let i = 0; i < patharray.length; i++){ + + for (let i = 0; i < patharray.length; i++) { json = json[patharray[i]]; } - if(typeof(json) === "undefined"){ - return "Node Not Available"; - } else if(typeof(json) === "object"){ + if (typeof json === 'undefined') { + return 'Node Not Available'; + } else if (typeof json === 'object') { var tempArr = []; - - for(var obj in json){ - tempArr.push([obj,json[obj]]); + + for (var obj in json) { + tempArr.push([obj, json[obj]]); } return tempArr; - } else if(typeof(json) !== "object") { + } else if (typeof json !== 'object') { return json; } - } - catch(err){ - return "Error getting data"; + } catch (err) { + return 'Error getting data'; } } diff --git a/src/macros.ts b/src/macros.ts index 6a83748..0839fe7 100644 --- a/src/macros.ts +++ b/src/macros.ts @@ -7,7 +7,9 @@ const macroDeleteDuplicateTransaction = () => { const cellOfRowToDelete = sheet.getCurrentCell(); if (cellOfRowToDelete.getColumn() !== BALANCE_COLUMN) { - SpreadsheetApp.getUi().alert('Please select the balance column of the row to be deleted and try again'); + SpreadsheetApp.getUi().alert( + 'Please select the balance column of the row to be deleted and try again' + ); return; } const cellBelow = cellOfRowToDelete.offset(1, 0); @@ -17,5 +19,9 @@ const macroDeleteDuplicateTransaction = () => { // delete the selected row (cellBelow will be moved up and be at the deleted row index) sheet.deleteRow(cellOfRowToDelete.getRow()); // copy the formula back into the hardcoded value we put in the cell earlier (as if nothing happened ;) - cellBelow.copyTo(cellOfRowToDelete, SpreadsheetApp.CopyPasteType.PASTE_FORMULA, false); + cellBelow.copyTo( + cellOfRowToDelete, + SpreadsheetApp.CopyPasteType.PASTE_FORMULA, + false + ); }; diff --git a/src/page-css.html b/src/page-css.html index b3fda52..b40083f 100644 --- a/src/page-css.html +++ b/src/page-css.html @@ -2,4 +2,4 @@ #import-table { /* margin-top: 30px; */ } - \ No newline at end of file + diff --git a/src/page-js.html b/src/page-js.html index f4202fe..2667171 100644 --- a/src/page-js.html +++ b/src/page-js.html @@ -4,7 +4,7 @@ const renderStrategyOptions = (strategies) => { const elemSelect = document.getElementById('import-strategy'); for (const strategy in strategies) { - elemSelect.add(new Option(strategy, strategies[strategy])) + elemSelect.add(new Option(strategy, strategies[strategy])); } }; @@ -13,7 +13,7 @@ return csvData.map((row) => { let jsonRow = {}; row.forEach((value, index) => { - jsonRow[headers[index]] = value; + jsonRow[headers[index]] = value; }); return jsonRow; }); @@ -31,15 +31,15 @@ .withFailureHandler(onFailure) .getStrategyOptions(); - table = new Tabulator("#import-table", { + table = new Tabulator('#import-table', { autoColumns: true, // layout: "fitColumns", autoColumnsDefinitions: (definitions) => { - definitions.forEach(definition => definition['headerSort'] = false); + definitions.forEach((definition) => (definition['headerSort'] = false)); return definitions; - } + }, }); }; - window.addEventListener("load", onLoad); + window.addEventListener('load', onLoad); diff --git a/src/types.ts b/src/types.ts index de737f6..c06e36f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,7 @@ enum n26Cols { Amount, AmountForeignCurrency, ForeignCurrencyType, - ExchangeRate + ExchangeRate, } enum raboCols { @@ -44,53 +44,57 @@ enum bbvaCols { Currency, Available, // <-- balance CurrencyOfAvailable, - Comments + Comments, } enum openbankCols { - + Fecha, + FechaValor, + Concepto, + Importe, + Saldo, } -type InputColumn = n26Cols | raboCols | bbvaCols +type InputColumn = n26Cols | raboCols | bbvaCols | openbankCols; enum StrategyOption { - N26 = "n26", - RABO = "rabobank", - BBVA = "bbva", - OPENBANK = "openbank" + N26 = 'n26', + RABO = 'rabobank', + BBVA = 'bbva', + OPENBANK = 'openbank', } -type Table = string[][] +type Table = string[][]; /** * A column function returns the values for that column * it can generate the column based on the data in the CSV */ - type ColumnRule = (data: Table) => T[] +type ColumnRule = (data: Table) => T[]; interface FireColumnRules { - ref: ColumnRule, - iban: ColumnRule, - date: ColumnRule, - amount: ColumnRule, - contra_account?: ColumnRule, - description?: ColumnRule, - satisfaction?: ColumnRule, - category: ColumnRule, - label?: ColumnRule, - contra_iban: ColumnRule, - currency?: ColumnRule + ref: ColumnRule; + iban: ColumnRule; + date: ColumnRule; + amount: ColumnRule; + contra_account?: ColumnRule; + description?: ColumnRule; + satisfaction?: ColumnRule; + category: ColumnRule; + label?: ColumnRule; + contra_iban: ColumnRule; + currency?: ColumnRule; } type Strategy = { [key in StrategyOption]: { - beforeImport?: Array<(data: Table) => Table>, - columnImportRules: FireColumnRules, - afterImport?: Array<(data: Table) => void>, - autoFillColumns?: number[] - } -} + beforeImport?: Array<(data: Table) => Table>; + columnImportRules: FireColumnRules; + afterImport?: Array<(data: Table) => void>; + autoFillColumns?: number[]; + }; +}; type ServerResponse = { - message: string -} + message: string; +}; diff --git a/src/utils.ts b/src/utils.ts index 0ed341a..0392f0e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,69 +3,100 @@ class Utils { * @see https://github.com/ramda/ramda/blob/v0.27.0/source/transpose.js */ static transpose(outerlist: T[][]): T[][] { - let i = 0 - let result = [] + let i = 0; + let result = []; while (i < outerlist.length) { - let innerlist = outerlist[i] - let j = 0 + let innerlist = outerlist[i]; + let j = 0; while (j < innerlist.length) { if (typeof result[j] === 'undefined') { - result[j] = [] + result[j] = []; } - result[j].push(innerlist[j]) - j += 1 + result[j].push(innerlist[j]); + j += 1; } - i += 1 + i += 1; } - return result + return result; } - + static importData(data: Table) { - const rowCount = data.length - const colCount = data[0].length - Logger.log(`importing data (rows: ${rowCount}, cols: ${colCount})`) + const rowCount = data.length; + const colCount = data[0].length; + Logger.log(`importing data (rows: ${rowCount}, cols: ${colCount})`); sourceSheet .insertRowsBefore(2, rowCount) .getRange(2, 1, rowCount, colCount) - .setValues(data as Table) + .setValues(data as Table); } static autoFillColumns(data: Table, columns: number[]) { for (const column of columns) { - const rowCount = data.length - const sourceRange = sourceSheet.getRange(2 + rowCount, column) - const destinationRange = sourceSheet.getRange(2, column, rowCount + 1) // + 1 because sourceRange needs to be included - sourceRange.autoFill(destinationRange, SpreadsheetApp.AutoFillSeries.DEFAULT_SERIES) + const rowCount = data.length; + const sourceRange = sourceSheet.getRange(2 + rowCount, column); + const destinationRange = sourceSheet.getRange(2, column, rowCount + 1); // + 1 because sourceRange needs to be included + sourceRange.autoFill( + destinationRange, + SpreadsheetApp.AutoFillSeries.DEFAULT_SERIES + ); } } static ensureLength(arr: any[], length: number) { if (arr.length < length) { - arr = arr.fill(null, arr.length, length - 1) + arr = arr.fill(null, arr.length, length - 1); } - return arr + return arr; } static deleteFirstRow(data: Table): Table { - data.shift() - return data + data.shift(); + return data; } - + static deleteLastRow(data: Table): Table { - data.pop() - return data + data.pop(); + return data; } - + + static deleteColumns(table: Table, colIndices: number[]): Table { + // we want to have the indices sorted backwards to prevent shifting of elements + // while traversing the array + const sortedIndices = colIndices.sort().reverse(); + // tranpose the table so we are working with columns first instead of rows + let transposedTable = this.transpose(table); + Logger.log('transposed'); + Logger.log(transposedTable); + // delIndex is the column index to delete in the table + for (const delIndex of sortedIndices) { + if (typeof transposedTable[delIndex] !== 'undefined') { + transposedTable.splice(delIndex, 1); + } + } + // transpose again back to rows first + return this.transpose(transposedTable); + } + static sortByDate(dateColumn: InputColumn) { return (data: Table) => { - data.sort( - (row1, row2) => new Date(row1[dateColumn]).getUTCDate() - new Date(row2[dateColumn]).getUTCDate() - ).reverse() - return data - } + data + .sort( + (row1, row2) => + new Date(row1[dateColumn]).getUTCDate() - + new Date(row2[dateColumn]).getUTCDate() + ) + .reverse(); + return data; + }; } - static transformMoneyColumn = (value: string, decimalSeparator: string = ',') => parseFloat( - value.replace(/\./g,'').replace(decimalSeparator, '.') - ) + static transformMoneyColumn = ( + value: string, + decimalSeparator: string = ',' + ) => parseFloat(value.replace(/\./g, '').replace(decimalSeparator, '.')); + + static transformDate = (value: string, separator: string = '/'): Date => { + const [day, month, year] = value.split(separator); + return new Date(+year, +month - 1, +day); + }; } diff --git a/tsconfig.json b/tsconfig.json index 98cae23..5c8266d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,4 +3,4 @@ "lib": ["ES2019"], "experimentalDecorators": true } -} \ No newline at end of file +}