diff --git a/.eslintrc b/.eslintrc index 0eebb9e..b4fa3a3 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,6 +3,8 @@ "arrow-body-style": 0, "no-shadow": 0, "no-console": 0, + "no-underscore-dangle": ["error", { "allow": ["_private"] }], + "class-methods-use-this": 0, "no-await-in-loop": 0 }, "globals": { diff --git a/.gitignore b/.gitignore index 5e9ef11..4574cf3 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ lib/ # OS related .DS_Store + +# IDEs +.idea \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 060928a..72567d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -934,6 +934,13 @@ "integrity": "sha1-9TsFJmqLGguTSz0IIebi3FkUriM=", "requires": { "colors": "1.0.3" + }, + "dependencies": { + "colors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + } } }, "cli-width": { @@ -996,9 +1003,9 @@ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "colors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", - "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.2.1.tgz", + "integrity": "sha512-s8+wktIuDSLffCywiwSxQOMqtPxML11a/dtHE17tMn4B1MSWw/C22EKf7M2KGUBcDaVFEGT+S8N02geDXeuNKg==" }, "commander": { "version": "2.12.2", diff --git a/package.json b/package.json index f3a78d1..b3a5e26 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "babel-polyfill": "^6.26.0", + "colors": "^1.2.1", "inquirer": "^4.0.1", "israeli-bank-scrapers": "^0.5.5", "json2csv": "^3.11.5", diff --git a/src/constants.js b/src/constants.js index cc4c7e0..60f8894 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,2 +1,3 @@ const PASSWORD_FIELD = 'password'; -export default PASSWORD_FIELD; +const DATE_TIME_FORMAT = 'DD-MM-YYYY_HH-mm-ss'; +export { PASSWORD_FIELD, DATE_TIME_FORMAT }; diff --git a/src/definitions.js b/src/definitions.js index f094637..8d973cc 100644 --- a/src/definitions.js +++ b/src/definitions.js @@ -2,4 +2,5 @@ import os from 'os'; export const CONFIG_FOLDER = `${os.homedir()}/.ynab-updater`; export const SETTINGS_FILE = `${CONFIG_FOLDER}/settings.json`; +export const TASKS_FOLDER = `${CONFIG_FOLDER}/tasks`; export const DOWNLOAD_FOLDER = `${os.homedir()}/Downloads/Transactions`; diff --git a/src/helpers/files.js b/src/helpers/files.js index b50b2cc..9174aab 100644 --- a/src/helpers/files.js +++ b/src/helpers/files.js @@ -6,19 +6,39 @@ import jsonfile from 'jsonfile'; const writeFileAsync = util.promisify(fs.writeFile); const existsAsync = util.promisify(fs.exists); const makeDirAsync = util.promisify(fs.mkdir); +const readdirAsync = util.promisify(fs.readdir); +const deleteFileAsync = util.promisify(fs.unlink); const readJsonFileAsync = util.promisify(jsonfile.readFile); const writeJsonFileAsync = util.promisify(jsonfile.writeFile); -async function verifyFolder(filePath) { - const folder = path.dirname(filePath); - const exists = await existsAsync(folder); - if (!exists) { - await makeDirAsync(folder); +async function verifyFolder(folderPath) { + const pathTokens = folderPath.split(path.sep); + let currentPath = ''; + for (let i = 0; i < pathTokens.length; i += 1) { + const folder = pathTokens[i]; + currentPath += folder + path.sep; + if (!await existsAsync(currentPath)) { + await makeDirAsync(currentPath); + } + } +} + +export async function getFolderFiles(folderPath, suffix) { + await verifyFolder(folderPath); + const files = await readdirAsync(folderPath); + if (suffix) { + return files.filter(filePath => (path.extname(filePath) || '').toLowerCase() === suffix.toLowerCase()); } + return files; +} + +export async function deleteFile(filePath) { + return deleteFileAsync(filePath); } export async function writeFile(filePath, data, options) { - await verifyFolder(filePath); + const folderPath = path.dirname(filePath); + await verifyFolder(folderPath); return writeFileAsync(filePath, data, options); } @@ -31,6 +51,7 @@ export async function readJsonFile(filePath, options) { } export async function writeJsonFile(filePath, obj, options) { - await verifyFolder(filePath); + const folderPath = path.dirname(filePath); + await verifyFolder(folderPath); await writeJsonFileAsync(filePath, obj, options); } diff --git a/src/helpers/tasks.js b/src/helpers/tasks.js new file mode 100644 index 0000000..37043ea --- /dev/null +++ b/src/helpers/tasks.js @@ -0,0 +1,84 @@ +import path from 'path'; +import colors from 'colors/safe'; +import moment from 'moment'; +import { writeJsonFile, readJsonFile, getFolderFiles, deleteFile } from '../helpers/files'; +import { TASKS_FOLDER } from '../definitions'; +import { SCRAPERS } from '../helpers/scrapers'; + +function writeSummaryLine(key, value) { + console.log(`- ${colors.bold(key)}: ${value}`); +} + +function printTaskSummary(taskData, shouldCalculateStartDate = false) { + if (taskData) { + const scrapers = taskData.scrapers || []; + const { + dateDiffByMonth, + combineInstallments, + } = taskData.options; + const { + combineReport, + saveLocation, + includeFutureTransactions, + } = taskData.output; + console.log(colors.underline.bold('Task Summary')); + writeSummaryLine('Scrapers', scrapers.map(scraper => SCRAPERS[scraper.id].name).join(', ')); + + if (shouldCalculateStartDate) { + const substractValue = dateDiffByMonth - 1; + const startMoment = moment().subtract(substractValue, 'month').startOf('month'); + writeSummaryLine('Start scraping from', startMoment.format('ll')); + } else { + writeSummaryLine('Scrape # of months', dateDiffByMonth); + } + + writeSummaryLine('Combine installments', combineInstallments ? 'Yes' : 'No'); + writeSummaryLine('Save to location', saveLocation); + writeSummaryLine('Create single report', combineReport ? 'Yes' : 'No'); + writeSummaryLine('Include future Transactions', includeFutureTransactions ? 'Yes' : 'No'); + } +} + +class TasksManager { + async getTasksList() { + const files = await getFolderFiles(TASKS_FOLDER, '.json'); + const result = files.map(file => path.basename(file, '.json')); + return result; + } + + async hasTask(taskName) { + const tasksList = await this.getTasksList(); + return taskName && !!tasksList.find(taskListName => + taskListName.toLowerCase() === taskName.toLowerCase()); + } + + isValidTaskName(taskName) { + return taskName && taskName.match(/^[a-zA-Z0-9_-]+$/); + } + + async loadTask(taskName) { + if (taskName && this.hasTask(taskName)) { + return readJsonFile(`${TASKS_FOLDER}/${taskName}.json`); + } + + throw new Error(`failed to find a task named ${taskName}`); + } + + async saveTask(taskName, taskData) { + if (taskName && this.isValidTaskName(taskName)) { + return writeJsonFile(`${TASKS_FOLDER}/${taskName}.json`, taskData); + } + + throw new Error(`invalid task name provided ${taskName}`); + } + + async deleteTask(taskName) { + if (taskName && this.hasTask(taskName)) { + await deleteFile(`${TASKS_FOLDER}/${taskName}.json`); + } else { + throw new Error(`invalid task name provided ${taskName}`); + } + } +} + +export { TasksManager, printTaskSummary }; diff --git a/src/index.js b/src/index.js index 8db3db2..edf1a9c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,13 @@ import yargs from 'yargs'; -import scrape from './scrape/scrape-individual'; +import colors from 'colors/safe'; import setupMainMenu from './setup/setup-main-menu'; +import scrapingMainMenu from './scrape/scraping-main-menu'; + +// set theme +colors.setTheme({ + title: 'bgCyan', + notify: 'magenta', +}); const args = yargs.options({ mode: { @@ -16,7 +23,7 @@ const args = yargs.options({ }).help().argv; if (!args.mode || args.mode === 'scrape') { - scrape(args.show); + scrapingMainMenu(args.show); } else if (args.mode === 'setup') { setupMainMenu(); } diff --git a/src/scrape/generate-reports.js b/src/scrape/generate-reports.js new file mode 100644 index 0000000..90d33b0 --- /dev/null +++ b/src/scrape/generate-reports.js @@ -0,0 +1,79 @@ +import json2csv from 'json2csv'; +import colors from 'colors/safe'; +import moment from 'moment'; +import { DATE_TIME_FORMAT } from '../constants'; +import { writeFile } from '../helpers/files'; + +function getReportFields(isSingleReport) { + const result = [ + { + label: 'Date', + value: row => row.dateMoment.format('DD/MM/YYYY'), + }, + { + label: 'Payee', + value: 'payee', + }, + { + label: 'Inflow', + value: 'amount', + }, + { + label: 'Installment', + value: 'installment', + }, + { + label: 'Total', + value: 'total', + }, + ]; + + if (isSingleReport) { + result.unshift( + { + label: 'Company', + value: 'company', + }, + { + label: 'Account', + value: 'account', + }, + ); + } + + return result; +} + +async function exportAccountData(account, saveLocation) { + const fields = getReportFields(false); + const csv = json2csv({ data: account.txns, fields, withBOM: true }); + await writeFile(`${saveLocation}/${account.scraperName} (${account.accountNumber}).csv`, csv); +} + +export async function generateSeparatedReports(scrapedAccounts, saveLocation) { + let numFiles = 0; + for (let i = 0; i < scrapedAccounts.length; i += 1) { + const account = scrapedAccounts[i]; + if (account.txns.length) { + console.log(colors.notify(`exporting ${account.txns.length} transactions for account # ${account.accountNumber}`)); + await exportAccountData(account, saveLocation); + numFiles += 1; + } else { + console.log(`no transactions for account # ${account.accountNumber}`); + } + } + + console.log(colors.notify(`${numFiles} csv files saved under ${saveLocation}`)); +} + +export async function generateSingleReport(scrapedAccounts, saveLocation) { + const fileTransactions = scrapedAccounts.reduce((acc, account) => { + acc.push(...account.txns); + return acc; + }, []); + const filePath = `${saveLocation}/${moment().format(DATE_TIME_FORMAT)}.csv`; + const fileFields = getReportFields(true); + const fileContent = json2csv({ data: fileTransactions, fields: fileFields, withBOM: true }); + await writeFile(filePath, fileContent); + console.log(colors.notify(`created file ${filePath}`)); +} diff --git a/src/scrape/scrape-base.js b/src/scrape/scrape-base.js new file mode 100644 index 0000000..8c00149 --- /dev/null +++ b/src/scrape/scrape-base.js @@ -0,0 +1,65 @@ +import moment from 'moment'; +import { SCRAPERS, createScraper } from '../helpers/scrapers'; + +async function prepareResults(scraperId, scraperName, scraperResult, combineInstallments) { + return scraperResult.accounts.map((account) => { + console.log(`${scraperName}: scraped ${account.txns.length} transactions from account ${account.accountNumber}`); + + const txns = account.txns.map((txn) => { + return { + company: scraperName, + account: account.accountNumber, + dateMoment: moment(txn.date), + payee: txn.description, + amount: txn.type !== 'installments' || !combineInstallments ? txn.chargedAmount : txn.originalAmount, + installment: txn.installments ? txn.installments.number : null, + total: txn.installments ? txn.installments.total : null, + }; + }); + + return { + scraperId, + scraperName, + accountNumber: account.accountNumber, + txns, + }; + }); +} + +export default async function (scraperId, credentials, options) { + const { + combineInstallments, + startDate, + showBrowser, + } = options; + + const scraperOptions = { + companyId: scraperId, + startDate, + combineInstallments, + showBrowser, + verbose: false, + }; + const scraperName = SCRAPERS[scraperId] ? + SCRAPERS[scraperId].name : null; + + if (!scraperName) { + throw new Error(`unknown scraper with id ${scraperId}`); + } + console.log(`scraping ${scraperName}`); + + const scraper = createScraper(scraperOptions); + scraper.onProgress((companyId, payload) => { + console.log(`${scraperName}: ${payload.type}`); + }); + const scraperResult = await scraper.scrape(credentials); + + console.log(`success: ${scraperResult.success}`); + if (!scraperResult.success) { + console.log(`error type: ${scraperResult.errorType}`); + console.log('error:', scraperResult.errorMessage); + throw new Error(scraperResult.errorMessage); + } + + return prepareResults(scraperId, scraperName, scraperResult, combineInstallments); +} diff --git a/src/scrape/scrape-individual.js b/src/scrape/scrape-individual.js index 29d9b37..fc0066a 100644 --- a/src/scrape/scrape-individual.js +++ b/src/scrape/scrape-individual.js @@ -1,12 +1,13 @@ import moment from 'moment'; import inquirer from 'inquirer'; -import json2csv from 'json2csv'; import { CONFIG_FOLDER } from '../definitions'; -import { writeFile, readJsonFile } from '../helpers/files'; +import { readJsonFile } from '../helpers/files'; import { decryptCredentials } from '../helpers/credentials'; -import { SCRAPERS, createScraper } from '../helpers/scrapers'; +import { SCRAPERS } from '../helpers/scrapers'; import { readSettingsFile, writeSettingsFile } from '../helpers/settings'; +import scrape from './scrape-base'; +import { generateSeparatedReports } from './generate-reports'; async function getParameters(defaultSaveLocation) { const startOfMonthMoment = moment().startOf('month'); @@ -52,20 +53,6 @@ async function getParameters(defaultSaveLocation) { return result; } -async function exportAccountData(scraperId, account, combineInstallments, saveLocation) { - const txns = account.txns.map((txn) => { - return { - Date: moment(txn.date).format('DD/MM/YYYY'), - Payee: txn.description, - Inflow: txn.type !== 'installments' || !combineInstallments ? txn.chargedAmount : txn.originalAmount, - Installment: txn.installments ? txn.installments.number : null, - Total: txn.installments ? txn.installments.total : null, - }; - }); - const fields = ['Date', 'Payee', 'Inflow', 'Installment', 'Total']; - const csv = json2csv({ data: txns, fields, withBOM: true }); - await writeFile(`${saveLocation}/${SCRAPERS[scraperId].name} (${account.accountNumber}).csv`, csv); -} export default async function (showBrowser) { const settings = await readSettingsFile(); @@ -85,42 +72,16 @@ export default async function (showBrowser) { if (encryptedCredentials) { const credentials = decryptCredentials(encryptedCredentials); const options = { - companyId: scraperId, startDate: startDate.toDate(), combineInstallments, showBrowser, - verbose: false, }; - let result; + try { - const scraper = createScraper(options); - scraper.onProgress((companyId, payload) => { - const name = SCRAPERS[companyId] ? SCRAPERS[companyId].name : companyId; - console.log(`${name}: ${payload.type}`); - }); - result = await scraper.scrape(credentials); + const scrapedAccounts = await scrape(scraperId, credentials, options); + await generateSeparatedReports(scrapedAccounts, saveLocation); } catch (e) { console.error(e); - throw e; - } - console.log(`success: ${result.success}`); - if (result.success) { - let numFiles = 0; - for (let i = 0; i < result.accounts.length; i += 1) { - const account = result.accounts[i]; - if (account.txns.length) { - console.log(`exporting ${account.txns.length} transactions for account # ${account.accountNumber}`); - await exportAccountData(scraperId, account, combineInstallments, saveLocation); - numFiles += 1; - } else { - console.log(`no transactions for account # ${account.accountNumber}`); - } - } - - console.log(`${numFiles} csv files saved under ${saveLocation}`); - } else { - console.log(`error type: ${result.errorType}`); - console.log('error:', result.errorMessage); } } else { console.log('Could not find credentials file'); diff --git a/src/scrape/scrape-task.js b/src/scrape/scrape-task.js new file mode 100644 index 0000000..77b252b --- /dev/null +++ b/src/scrape/scrape-task.js @@ -0,0 +1,111 @@ +import moment from 'moment'; +import inquirer from 'inquirer'; +import colors from 'colors/safe'; +import { DATE_TIME_FORMAT } from '../constants'; +import { decryptCredentials } from '../helpers/credentials'; +import scrape from './scrape-base'; +import { TasksManager, printTaskSummary } from '../helpers/tasks'; +import { generateSingleReport, generateSeparatedReports } from './generate-reports'; + +const tasksManager = new TasksManager(); + +async function getParameters() { + const availableTasks = await tasksManager.getTasksList(); + + if (availableTasks && availableTasks.length) { + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'taskName', + message: 'Select a task to execute', + validate: (answer) => { + if (!answer) { + return 'Task name must be provided'; + } + return true; + }, + choices: [...availableTasks], + }, + ]); + + return { taskName: answers.taskName }; + } + + console.log(colors.notify('No tasks created, please run command \'npm run setup\' to create a task')); + return { taskName: null }; +} + +export default async function (showBrowser) { + const { taskName } = await getParameters(); + + if (taskName) { + console.log(colors.title(`Running task '${taskName}'`)); + const taskData = await tasksManager.loadTask(taskName); + const scrapersOfTask = taskData.scrapers || []; + + if (scrapersOfTask.length === 0) { + console.log(colors.notify('Task has no scrapers defined.\nplease run command \'npm run setup\' and update task scrapers')); + return; + } + + printTaskSummary(taskData, true); + + const { + dateDiffByMonth, + combineInstallments, + } = taskData.options; + const { + combineReport, + saveLocation: saveLocationRootPath, + includeFutureTransactions, + } = taskData.output; + const substractValue = dateDiffByMonth - 1; + const startMoment = moment().subtract(substractValue, 'month').startOf('month'); + const reportAccounts = []; + + console.log(colors.title('Run task scrapers')); + + for (let i = 0; i < scrapersOfTask.length; i += 1) { + const scraperOfTask = scrapersOfTask[i]; + const credentials = decryptCredentials(scraperOfTask.credentials); + + const options = { + companyId: scraperOfTask.id, + startDate: startMoment, + combineInstallments, + showBrowser, + verbose: false, + }; + + try { + const scrapedAccounts = await scrape(scraperOfTask.id, credentials, options); + reportAccounts.push(...scrapedAccounts); + } catch (e) { + console.error(e); + throw e; + } + } + + if (includeFutureTransactions) { + const nowMoment = moment(); + for (let i = 0; i < reportAccounts.length; i += 1) { + const account = reportAccounts[i]; + if (account.txns) { + account.txns = account.txns.filter((txn) => { + const txnMoment = moment(txn.dateMoment); + return txnMoment.isSameOrBefore(nowMoment, 'day'); + }); + } + } + } + + if (combineReport) { + const saveLocation = `${saveLocationRootPath}/tasks/${taskName}`; + await generateSingleReport(reportAccounts, saveLocation); + } else { + const currentExecutionFolder = moment().format(DATE_TIME_FORMAT); + const saveLocation = `${saveLocationRootPath}/tasks/${taskName}/${currentExecutionFolder}`; + await generateSeparatedReports(reportAccounts, saveLocation); + } + } +} diff --git a/src/scrape/scraping-main-menu.js b/src/scrape/scraping-main-menu.js new file mode 100644 index 0000000..e5440ee --- /dev/null +++ b/src/scrape/scraping-main-menu.js @@ -0,0 +1,35 @@ +import inquirer from 'inquirer'; +import scrapeIndividual from './scrape-individual'; +import scrapeTask from './scrape-task'; + +export default async function (showBrowser) { + const RUN_SCRAPER_ACTION = 'scraper'; + const RUN_TASK_ACTION = 'task'; + + const { scrapeType } = await inquirer.prompt({ + type: 'list', + name: 'scrapeType', + message: 'What would you like to do?', + choices: [ + { + name: 'Run an individual scraper', + value: RUN_SCRAPER_ACTION, + }, + { + name: 'Run a task', + value: RUN_TASK_ACTION, + }, + ], + }); + + switch (scrapeType) { + case RUN_SCRAPER_ACTION: + await scrapeIndividual(showBrowser); + break; + case RUN_TASK_ACTION: + await scrapeTask(showBrowser); + break; + default: + break; + } +} diff --git a/src/setup/setup-main-menu.js b/src/setup/setup-main-menu.js index 0cd704b..ad4721d 100644 --- a/src/setup/setup-main-menu.js +++ b/src/setup/setup-main-menu.js @@ -1,8 +1,11 @@ import inquirer from 'inquirer'; +import setupTask from './tasks/setup-task'; import setupScrapers from './setup-scrapers'; export default async function () { + const SETUP_SCRAPER_ACTION = 'scraper'; + const SETUP_TASK_ACTION = 'task'; const { setupType } = await inquirer.prompt({ type: 'list', name: 'setupType', @@ -10,14 +13,21 @@ export default async function () { choices: [ { name: 'Setup a new scraper', - value: 'scraper', + value: SETUP_SCRAPER_ACTION, + }, + { + name: 'Setup a new task', + value: SETUP_TASK_ACTION, }, ], }); switch (setupType) { - case 'scraper': - setupScrapers(); + case SETUP_SCRAPER_ACTION: + await setupScrapers(); + break; + case SETUP_TASK_ACTION: + await setupTask(); break; default: break; diff --git a/src/setup/setup-scrapers.js b/src/setup/setup-scrapers.js index bba68ce..6f4aeab 100644 --- a/src/setup/setup-scrapers.js +++ b/src/setup/setup-scrapers.js @@ -1,6 +1,6 @@ import inquirer from 'inquirer'; -import PASSWORD_FIELD from '../constants'; +import { PASSWORD_FIELD } from '../constants'; import { CONFIG_FOLDER } from '../definitions'; import { SCRAPERS } from '../helpers/scrapers'; import { writeJsonFile } from '../helpers/files'; diff --git a/src/setup/tasks/create-task-handler.js b/src/setup/tasks/create-task-handler.js new file mode 100644 index 0000000..4bd368a --- /dev/null +++ b/src/setup/tasks/create-task-handler.js @@ -0,0 +1,63 @@ +import inquirer from 'inquirer'; +import colors from 'colors/safe'; +import { DOWNLOAD_FOLDER } from '../../definitions'; +import ModifyTaskHandler from './modify-task-handler'; +import { TasksManager } from '../../helpers/tasks'; + +const tasksManager = new TasksManager(); + +function createEmptyTaskData() { + return { + scrapers: [], + options: { + combineInstallments: false, + dateDiffByMonth: 3, + }, + output: { + saveLocation: DOWNLOAD_FOLDER, + combineReport: true, + includeFutureTransactions: false, + }, + }; +} + +export default class { + async run() { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: 'What is the task name (leave blank to cancel)', + validate: async (value) => { + if (value) { + const alreadyExists = await tasksManager.hasTask(value); + + if (alreadyExists) { + return 'A task with this name already exists, please type a unique name'; + } + + const invalidPattern = !tasksManager.isValidTaskName(value); + + if (invalidPattern) { + return 'The task name must include only these characters: A-Z, 0-9, -, _'; + } + } + + return true; + }, + }, + ]); + + const taskName = answers.name; + + if (taskName) { + const taskData = createEmptyTaskData(); + await tasksManager.saveTask(taskName, taskData); + const modifyTaskAdapter = new ModifyTaskHandler(taskName); + console.log(colors.notify(`Task '${taskName}' created`)); + await modifyTaskAdapter.run(); + } else { + console.log(colors.notify('Task creation cancelled')); + } + } +} diff --git a/src/setup/tasks/delete-task-handler.js b/src/setup/tasks/delete-task-handler.js new file mode 100644 index 0000000..3efb685 --- /dev/null +++ b/src/setup/tasks/delete-task-handler.js @@ -0,0 +1,60 @@ +import inquirer from 'inquirer'; +import colors from 'colors/safe'; +import { TasksManager } from '../../helpers/tasks'; + +const tasksManager = new TasksManager(); +const goBackOption = { + name: 'Go Back', + value: '', +}; + +const DeleteTaskHandler = (function createDeleteTaskHandler() { + const _private = new WeakMap(); + + class DeleteTaskHandler { + static async createAdapter() { + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'taskName', + message: 'Select a task to delete', + choices: [ + ...await tasksManager.getTasksList(), + goBackOption, + ], + }, + { + type: 'confirm', + name: 'confirmDelete', + message: 'Are you sure?', + when: answers => !!answers.taskName, + default: false, + }, + ]); + + if (answers.taskName && answers.confirmDelete) { + return new DeleteTaskHandler(answers.taskName); + } + console.log(colors.notify('Delete task cancelled')); + + return null; + } + + constructor(taskName) { + _private.set(this, { taskName }); + } + + async run() { + if (!_private.get(this).taskName) { + throw new Error('Missing task name'); + } + + console.log(colors.notify(`Task '${_private.get(this).taskName}' deleted`)); + await tasksManager.deleteTask(_private.get(this).taskName); + } + } + + return DeleteTaskHandler; +}()); + +export default DeleteTaskHandler; diff --git a/src/setup/tasks/modify-task-handler.js b/src/setup/tasks/modify-task-handler.js new file mode 100644 index 0000000..4fe6822 --- /dev/null +++ b/src/setup/tasks/modify-task-handler.js @@ -0,0 +1,315 @@ +import inquirer from 'inquirer'; +import colors from 'colors/safe'; +import { PASSWORD_FIELD } from '../../constants'; +import { SCRAPERS } from '../../helpers/scrapers'; +import { encryptCredentials } from '../../helpers/credentials'; +import { TasksManager, printTaskSummary } from '../../helpers/tasks'; + +const tasksManager = new TasksManager(); + +const goBackOption = { + name: 'Go Back', + value: '', +}; + +function getListOfScrapers(existingTaskScrapers) { + return Object.keys(SCRAPERS).map((scraperId) => { + const result = { value: scraperId, name: SCRAPERS[scraperId].name }; + const hasCredentials = existingTaskScrapers + .find(scraper => scraper.id === scraperId); + result.name = `${hasCredentials ? 'Edit' : 'Add'} ${result.name}`; + + return result; + }); +} + +function validateNonEmpty(field, input) { + if (input) { + return true; + } + return `${field} must be non empty`; +} + +const ModifyTaskHandler = (function createModifyTaskHandler() { + const _private = new WeakMap(); + + class ModifyTaskHandler { + static async createAdapter() { + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'taskName', + message: 'Select a task to modify', + choices: [...await tasksManager.getTasksList(), goBackOption], + }, + ]); + + if (answers.taskName) { + return new ModifyTaskHandler(answers.taskName); + } + + return null; + } + + constructor(taskName) { + _private.set(this, { taskName }); + } + + /* + * @private + */ + async manageOptions() { + const { + combineInstallments, + dateDiffByMonth, + } = _private.get(this).taskData.options; + + const { + saveLocation, + combineReport, + includeFutureTransactions, + } = _private.get(this).taskData.output; + + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'combineInstallments', + message: 'Combine installment transactions?', + default: combineInstallments, + }, + { + type: 'input', + name: 'dateDiffByMonth', + message: 'How many months do you want to scrape (1-12)?', + default: dateDiffByMonth, + filter: (value) => { + if (Number.isFinite(value) && !Number.isNaN(value)) { + return value; + } else if (typeof value === 'string' && value.match(/^[0-9]+$/)) { + return value * 1; + } + + return null; + }, + validate: (value) => { + const pass = value !== null && value >= 1 && value <= 12; + + if (pass) { + return true; + } + + return 'Please enter a value between 1 and 12'; + }, + }, + { + type: 'input', + name: 'saveLocation', + message: 'Save folder?', + default: saveLocation, + }, + { + type: 'confirm', + name: 'combineReport', + message: 'Combine all accounts into a single report?', + default: !!combineReport, + }, + { + type: 'confirm', + name: 'includeFutureTransactions', + message: 'Include future transactions?', + default: !!includeFutureTransactions, + }, + ]); + + _private.get(this).taskData.options.combineInstallments = answers.combineInstallments; + _private.get(this).taskData.options.dateDiffByMonth = answers.dateDiffByMonth; + _private.get(this).taskData.output.saveLocation = answers.saveLocation; + _private.get(this).taskData.output.combineReport = answers.combineReport; + _private.get(this).taskData.output.includeFutureTransactions = + answers.includeFutureTransactions; + console.log(colors.notify('Changes saved')); + await this.saveTask(); + } + + /* + * @private + */ + async manageScrapers() { + const MODIFY_ACTION = 'modify'; + const DELETE_ACTION = 'delete'; + + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: 'What do you want to do?', + choices: [ + { + name: 'Add / Edit a scraper', + value: MODIFY_ACTION, + }, + { + name: 'Delete a scraper', + value: DELETE_ACTION, + }, + goBackOption, + ], + }, + { + type: 'list', + name: 'scraperId', + message: 'Select a scraper', + when: (answers) => { + if (answers.action === DELETE_ACTION) { + const hasScrapers = _private.get(this).taskData.scrapers.length !== 0; + + if (!hasScrapers) { + console.log(colors.notify('task has no scrapers defined')); + } + + return hasScrapers; + } + + const isRelevantQuestion = answers.action === MODIFY_ACTION; + return isRelevantQuestion; + }, + choices: (answers) => { + if (answers.action === MODIFY_ACTION) { + return [...getListOfScrapers(_private.get(this).taskData.scrapers), goBackOption]; + } + + const taskScrapers = _private.get(this).taskData.scrapers.map(scraper => ( + { + value: scraper.id, + name: SCRAPERS[scraper.id].name, + })); + + return [...taskScrapers, goBackOption]; + }, + }, + { + type: 'confirm', + name: 'confirmDelete', + when: answers => answers.action === DELETE_ACTION && answers.scraperId, + message: 'Are you sure?', + default: false, + }, + ]); + + const { scraperId, action, confirmDelete } = answers; + + if (scraperId) { + if (action === DELETE_ACTION) { + if (confirmDelete) { + console.log(colors.notify(`Scraper ${scraperId} deleted`)); + _private.get(this).taskData.scrapers = _private.get(this) + .taskData.scrapers.filter(item => item.id !== scraperId); + await this.saveTask(); + } else { + console.log(colors.notify('Delete scraper cancelled')); + } + } else if (action === MODIFY_ACTION) { + const { loginFields } = SCRAPERS[scraperId]; + const questions = loginFields.map((field) => { + return { + type: field === PASSWORD_FIELD ? PASSWORD_FIELD : 'input', + name: field, + message: `Enter value for ${field}:`, + validate: input => validateNonEmpty(field, input), + }; + }); + const credentialsResult = await inquirer.prompt(questions); + const encryptedCredentials = encryptCredentials(credentialsResult); + + const scraperData = _private.get(this).taskData.scrapers + .find(scraper => scraper.id === scraperId); + if (!scraperData) { + _private.get(this).taskData.scrapers + .push({ id: scraperId, credentials: encryptedCredentials }); + console.log(colors.notify(`'${scraperId}' scrapper added`)); + } else { + scraperData.credentials = encryptedCredentials; + console.log(colors.notify(`'${scraperId}' scrapper updated`)); + } + await this.saveTask(); + } + } + } + + /* + * @private + */ + async saveTask() { + await tasksManager.saveTask(_private.get(this).taskName, _private.get(this).taskData); + } + + async run() { + const VIEW_SUMMARY_ACTION = 'summary'; + const UPDATE_SCRAPERS_LIST_ACTION = 'scrapers-list'; + const UPDATE_OPTIONS_ACTION = 'scraping-options'; + + let firstTimeEntering = false; + + if (!_private.get(this).taskName) { + throw new Error('missing task name'); + } + + if (!_private.get(this).taskData) { + firstTimeEntering = true; + _private.get(this).taskData = await tasksManager.loadTask(_private.get(this).taskName); + } + + if (_private.get(this).taskData) { + if (firstTimeEntering) { + console.log(colors.title(`Editing task '${_private.get(this).taskName}'`)); + } + + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: 'What do you want to do?', + choices: [ + { + name: 'View task summary', + value: VIEW_SUMMARY_ACTION, + }, + { + name: 'Update scrapers list', + value: UPDATE_SCRAPERS_LIST_ACTION, + }, + { + name: 'Update scraping options', + value: UPDATE_OPTIONS_ACTION, + }, + goBackOption, + ], + }, + ]); + + switch (answers.action) { + case VIEW_SUMMARY_ACTION: + console.log(''); // print empty line + printTaskSummary(_private.get(this).taskData, false); + console.log(''); // print empty line + await this.run(); + break; + case UPDATE_SCRAPERS_LIST_ACTION: + await this.manageScrapers(); + await this.run(); + break; + case UPDATE_OPTIONS_ACTION: + await this.manageOptions(); + await this.run(); + break; + default: + break; + } + } + } + } + + return ModifyTaskHandler; +}()); + +export default ModifyTaskHandler; diff --git a/src/setup/tasks/setup-task.js b/src/setup/tasks/setup-task.js new file mode 100644 index 0000000..1b3a728 --- /dev/null +++ b/src/setup/tasks/setup-task.js @@ -0,0 +1,72 @@ +import inquirer from 'inquirer'; +import ModifyTaskHandler from './modify-task-handler'; +import CreateTaskHandler from './create-task-handler'; +import DeleteTaskHandler from './delete-task-handler'; + + +async function selectAction() { + const CREATE_NEW_TASK_ACTION = 'new'; + const MODIFY_TASK_ACTION = 'modify'; + const DELETE_TASK_ACTION = 'delete'; + const QUIT_ACTION = 'quit'; + + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: 'What do you want to do?', + choices: [ + { + name: 'Create a new task', + value: CREATE_NEW_TASK_ACTION, + }, + { + name: 'Modify an existing task', + value: MODIFY_TASK_ACTION, + }, + new inquirer.Separator(), + { + name: 'Delete a task', + value: DELETE_TASK_ACTION, + }, + { + name: 'Quit', + value: QUIT_ACTION, + }, + ], + }, + ]); + + switch (answers.action) { + case CREATE_NEW_TASK_ACTION: { + const createNewTaskAdapter = new CreateTaskHandler(); + await createNewTaskAdapter.run(); + await selectAction(); + } + break; + case MODIFY_TASK_ACTION: { + const adapter = await ModifyTaskHandler.createAdapter(); + + if (adapter) { + await adapter.run(); + } + + await selectAction(); + } + break; + case DELETE_TASK_ACTION: { + const adapter = await DeleteTaskHandler.createAdapter(); + + if (adapter) { + await adapter.run(); + } + + await selectAction(); + } + break; + default: + break; + } +} + +export default selectAction;