diff --git a/copay.js b/copay.js index fc4f7d4ec..bf94a1538 100644 --- a/copay.js +++ b/copay.js @@ -7,6 +7,7 @@ module.exports.HDPath = require('./js/models/HDPath'); module.exports.HDParams = require('./js/models/HDParams'); module.exports.crypto = require('./js/util/crypto'); module.exports.logger = require('./js/util/log'); +module.exports.csv = require('./js/util/csv'); // components diff --git a/js/controllers/history.js b/js/controllers/history.js index c50c12528..c3c875b8a 100644 --- a/js/controllers/history.js +++ b/js/controllers/history.js @@ -2,7 +2,7 @@ var bitcore = require('bitcore'); angular.module('copayApp.controllers').controller('HistoryController', - function($scope, $rootScope, $filter, rateService) { + function($scope, $rootScope, $filter, $timeout, rateService, notification) { var w = $rootScope.wallet; $rootScope.title = 'History'; @@ -26,27 +26,60 @@ angular.module('copayApp.controllers').controller('HistoryController', var w = $rootScope.wallet; if (!w) return; + var filename = "copay_history.csv"; + var descriptor = { + columns: [ + { label: 'Date', property: 'ts', type: 'date' }, + { label: 'Amount (' + w.settings.unitName + ')', property: 'amount', type: 'number' }, + { label: 'Amount (' + w.settings.alternativeIsoCode + ')', property: 'alternativeAmount' }, + { label: 'Action', property: 'action' }, + { label: 'AddressTo', property: 'addressTo' }, + { label: 'Comment', property: 'comment' }, + ], + }; + if (w.isShared()) { + descriptor.columns.push({ + label: 'Signers', + property: function(obj) { + if (!obj.actionList) return ''; + return _.map(obj.actionList, function(action) { + return w.publicKeyRing.nicknameForCopayer(action.cId); + }).join('|'); + } + }); + } + $scope.generating = true; - w.getTransactionHistoryCsv(function(csvContent) { - if (csvContent && csvContent !== 'ERROR') { - var filename = "copay_history.csv"; - - var encodedUri = encodeURI(csvContent); - var link = document.createElement("a"); - link.setAttribute("href", encodedUri); - link.setAttribute("download", filename); - - link.click(); + $scope._getTransactions(w, null, function(err, res) { + if (err) { + $scope.generating = false; + logger.error(err); + notification.error('Could not get transaction history'); + return; } - $scope.generating = false; - $scope.$digest(); - }) + $scope._addRates(w, res.items, function (err) { + copay.csv.toCsv(res.items, descriptor, function (err, res) { + if (err) { + $scope.generating = false; + logger.error(err); + notification.error('Could not generate csv file'); + return; + } + var csvContent = "data:text/csv;charset=utf-8," + res; + var encodedUri = encodeURI(csvContent); + var link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", filename); + link.click(); + $scope.generating = false; + $scope.$digest(); + }); + }); + }); }; - - $scope.update = function() { $scope.getTransactions(); }; @@ -58,6 +91,37 @@ angular.module('copayApp.controllers').controller('HistoryController', }, 1); }; + $scope._getTransactions = function (w, opts, cb) { + w.getTransactionHistory(opts, function(err, res) { + if (err) return cb(err); + if (!res) return cb(); + + var now = new Date(); + var items = res.items; + _.each(items, function(tx) { + tx.ts = tx.minedTs || tx.sentTs; + tx.rateTs = Math.floor((tx.ts || now) / 1000); + tx.amount = $filter('noFractionNumber')(tx.amount); + }); + return cb(null, res); + }); + }; + + $scope._addRates = function (w, txs, cb) { + if (!txs || txs.length == 0) return cb(); + var index = _.indexBy(txs, 'rateTs'); + rateService.getHistoricRates(w.settings.alternativeIsoCode, _.keys(index), function(err, res) { + if (err) return cb(err); + if (!res) return cb(); + _.each(res, function(r) { + var tx = index[r.ts]; + var alternativeAmount = (r.rate != null ? tx.amountSat * rateService.SAT_TO_BTC * r.rate : null); + tx.alternativeAmount = alternativeAmount ? $filter('noFractionNumber')(alternativeAmount, 2) : null; + }); + return cb(); + }); + }; + $scope.getTransactions = function() { var w = $rootScope.wallet; if (!w) return; @@ -65,7 +129,7 @@ angular.module('copayApp.controllers').controller('HistoryController', $scope.blockchain_txs = w.cached_txs || []; $scope.loading = true; - w.getTransactionHistory({ + $scope._getTransactions(w, { currentPage: $scope.currentPage, itemsPerPage: $scope.itemsPerPage, }, function(err, res) { @@ -78,28 +142,11 @@ angular.module('copayApp.controllers').controller('HistoryController', } var items = res.items; - var now = new Date(); - _.each(items, function(tx) { - tx.ts = tx.minedTs || tx.sentTs; - tx.rateTs = Math.floor((tx.ts || now) / 1000); - tx.amount = $filter('noFractionNumber')(tx.amount); - }); - - if (items.length > 0) { - var index = _.indexBy(items, 'rateTs'); - rateService.getHistoricRates(w.settings.alternativeIsoCode, _.keys(index), function(err, res) { - if (!err && res) { - _.each(res, function(r) { - var tx = index[r.ts]; - var alternativeAmount = (r.rate != null ? tx.amountSat * rateService.SAT_TO_BTC * r.rate : null); - tx.alternativeAmount = alternativeAmount ? $filter('noFractionNumber')(alternativeAmount, 2) : null; - }); - setTimeout(function() { - $scope.$digest(); - }, 1); - } - }); - } + $scope._addRates(w, items, function (err) { + $timeout(function() { + $scope.$digest(); + }, 1); + }) $scope.blockchain_txs = w.cached_txs = items; $scope.nbPages = res.nbPages; diff --git a/js/models/Wallet.js b/js/models/Wallet.js index e45a5edb9..c9aa01342 100644 --- a/js/models/Wallet.js +++ b/js/models/Wallet.js @@ -2543,85 +2543,6 @@ Wallet.prototype.isComplete = function() { return this.publicKeyRing.isComplete(); }; -/** - * @desc Return a list of transactions on CSV format - * @return {Object} the list of transactions on CSV format - */ -Wallet.prototype.getTransactionHistoryCsv = function(cb) { - var self = this; - self.getTransactionHistory(function(err, res) { - preconditions.checkState(res); - if (err) { - log.warn(err); - return cb(new Error('TXHISTORY: ' + err.toString())); - } - - var unit = self.settings.unitName; - var data = res.items; - - var csvContent = "data:text/csv;charset=utf-8,"; - csvContent += "Date,Amount(" + unit + "),Action,AddressTo,Comment"; - - if (self.isShared()) { - csvContent += ",Signers\n"; - } else { - csvContent += "\n"; - } - - data.forEach(function(it, index) { - if (!it) { - return cb(new Error('TXHISTORY: The item is null')); - } - var dataString = formatDate(it.minedTs || it.sentTs) + ',' + it.amount + ',' + it.action + ',' + formatString(it.addressTo) + ',' + formatString(it.comment); - if (self.isShared() && it.actionList) { - dataString += ',' + formatSigners(it.actionList); - } - csvContent += index < data.length ? dataString + "\n" : dataString; - }); - - - - return cb(csvContent); - - function formatDate(date) { - var dateObj = new Date(date); - if (!dateObj) { - log.warn('Error formating a date'); - return 'DateError' - } - if (!dateObj.toJSON()) { - return ''; - } - - return dateObj.toJSON().substring(0, 10); - } - - function formatString(str) { - if (!str) return ''; - - if (str.indexOf('"') !== -1) { - //replace all - str = str.replace(new RegExp('"', 'g'), '\''); - } - - //escaping commas - str = '\"' + str + '\"'; - - return str; - } - - function formatSigners(item) { - if (!item) return ''; - var str = ''; - item.forEach(function(it, index) { - str += index == 0 ? self.publicKeyRing.nicknameForCopayer(it.cId) : '|' + self.publicKeyRing.nicknameForCopayer(it.cId); - }); - return str; - } - - }); - -} /** * @desc Sets the version of this wallet object @@ -2741,7 +2662,6 @@ Wallet.prototype.getTransactionHistory = function(opts, cb) { var proposal = indexedProposals[tx.txid]; if (proposal) { - // TODO refactor tx.comment = proposal.comment; tx.sentTs = proposal.sentTs; tx.merchant = proposal.merchant; diff --git a/js/util/csv.js b/js/util/csv.js new file mode 100644 index 000000000..b573ea816 --- /dev/null +++ b/js/util/csv.js @@ -0,0 +1,95 @@ +/** + * Small module for exporting data to CSV. + */ +var _ = require('lodash'); +var preconditions = require('preconditions').singleton(); +var moment = require('moment'); + +var logger = require('../util/log.js'); +var config = require('../../config'); + + +var COL_DELIMITER = ','; +var ROW_DELIMITER = '\r\n'; + +function getValue(obj, property) { + if (_.isFunction(property)) { + try { + return property(obj); + } catch (err) { + if (_.isString(err)) return err; + return undefined; + } + } + if (!_.isObject(obj)) return undefined; + return obj.hasOwnProperty(property) ? obj[property] : undefined; +}; + +function formatValue(value, type, format) { + if (_.isUndefined(value) || _.isNull(value)) return ''; + + var r; + switch (type) { + default: + case 'string': + r = value.toString(); + r.replace('"', '\\"'); + break; + case 'date': + r = moment(value).format(format); + break; + case 'number': + r = value.toString(); + break; + } + + // escape when commas in values + if (r.indexOf(',') !== -1) { + r = '"' + r + '"'; + } + return r; +}; + +function getHeader(descriptor) { + return _.map(descriptor.columns, function (col) { + return col.label || (_.isString(col.property) ? col.property : '') || ''; + }); +}; + +function processDataRow(data, descriptor) { + return _.map(descriptor.columns, function (col) { + var value = getValue(data, col.property); + var formatted = formatValue(value, col.type, col.format); + return formatted; + }); +}; + +/** + * @desc Convert json object to csv based on a descriptor + * + * @param {array} data - the array of json objects to convert to csv + * @param {object} descriptor - an object that parameterizes the conversion + * @param {function} cb - called with the resulting csv + */ +module.exports.toCsv = function(data, descriptor, cb) { + preconditions.shouldBeArray(data); + preconditions.shouldBeObject(descriptor); + preconditions.shouldBeArray(descriptor.columns); + preconditions.shouldBeFunction(cb); + + var colDelimiter = descriptor.colDelimiter || COL_DELIMITER; + var rowDelimiter = descriptor.rowDelimiter || ROW_DELIMITER; + + var rows = _.map(data, function (dataRow) { + return processDataRow(dataRow, descriptor); + }); + + var header = getHeader(descriptor); + rows.unshift(header); + + var csv = _.reduce(rows, function (memo, row) { + return memo + row.join(colDelimiter) + rowDelimiter; + }, ''); + + return cb(null, csv); +}; diff --git a/package.json b/package.json index 108961d7e..8d19cc16c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "optimist": "^0.6.1", "preconditions": "^1.0.7", "querystring": "^0.2.0", - "request": "^2.40.0" + "request": "^2.40.0", + "moment": "2.6.0" }, "scripts": { "start": "node server.js", diff --git a/test/Wallet.js b/test/Wallet.js index 74d5be787..39fcb4718 100644 --- a/test/Wallet.js +++ b/test/Wallet.js @@ -2469,76 +2469,6 @@ describe('Wallet model', function() { }); }); - describe('#getTransactionHistoryCsv', function() { - it('should return list of txs', function(done) { - var w = cachedCreateW2(); - var txs = [{ - vin: [{ - addr: 'in_1', - valueSat: 1000 - }], - vout: [{ - scriptPubKey: { - addresses: ['out_1'], - }, - value: '0.00000900', - }], - fees: 0.00000100 - }, { - vin: [{ - addr: 'in_2', - valueSat: 2000 - }], - vout: [{ - scriptPubKey: { - addresses: ['out_2'], - }, - value: '0.00001900', - }], - fees: 0.00000100 - }, { - vin: [{ - addr: 'in_3', - valueSat: 3000 - - }], - vout: [{ - scriptPubKey: { - addresses: ['out_3'], - }, - value: '0.00002900', - - }], - fees: 0.00000100 - }]; - - w.blockchain.getTransactions = sinon.stub().yields(null, { - items: txs, - totalItems: txs.length, - }); - - sinon.stub(w, 'getAddresses').returns(['in_1', 'in_2', 'in_3', 'out_1', 'out_2', 'out_3']); - var s = sinon.stub(w.publicKeyRing, 'addressIsOwn'); - s.withArgs('in_1').returns(true); - s.withArgs('out_1').returns(false); - - s.withArgs('in_2').returns(false); - s.withArgs('out_2').returns(true); - - s.withArgs('in_3').returns(true); - s.withArgs('out_3').returns(true); - - - w.getTransactionHistoryCsv(function(data) { - data.should.exist; - data.should.equal('data:text/csv;charset=utf-8,Date,Amount(bits),Action,AddressTo,Comment,Signers\n,9,sent,"out_1",\n,0,moved,"out_2",\n,29,sent,"out_3",\n'); - done(); - }); - }); - - }); - - describe.skip('#read', function() { var network, blockchain; diff --git a/test/util.csv.js b/test/util.csv.js new file mode 100644 index 000000000..5e4b5cbeb --- /dev/null +++ b/test/util.csv.js @@ -0,0 +1,165 @@ +'use strict'; + +var _ = require('lodash'); +var chai = chai || require('chai'); +var sinon = sinon || require('sinon'); +var should = chai.should(); + +var csv = require('../js/util/csv') +var moment = moment || require('moment'); + +describe('csv utils', function() { + it('should convert simple json', function(done) { + var data = [ + { name: 'Lennon John', age: 40 }, + { name: 'Cobain, Kurt', age: 27 }, + ]; + + var descriptor = { + columns: [ + { label: 'Name', property: 'name', type: 'string' }, + { label: 'Age', property: 'age', type: 'number' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('Name,Age\r\nLennon John,40\r\n"Cobain, Kurt",27\r\n'); + done(); + }); + }); + it('should handle empty data', function(done) { + var data = []; + + var descriptor = { + columns: [ + { label: 'Name', property: 'name', type: 'string' }, + { label: 'Age', property: 'age', type: 'number' }, + { property: 'lastLogin', type: 'date' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('Name,Age,lastLogin\r\n'); + done(); + }); + }); + it('should handle null row in data', function(done) { + var data = [ + { name: 'John', age: 40 }, + null, + { name: 'Kurt', age: 27 }, + ]; + + var descriptor = { + columns: [ + { label: 'Name', property: 'name', type: 'string' }, + { label: 'Age', property: 'age', type: 'number' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('Name,Age\r\nJohn,40\r\n,\r\nKurt,27\r\n'); + done(); + }); + }); + it('should format dates', function(done) { + var data = [ + { name: 'John', age: 40, lastLogin: moment(1417608870000), }, + { name: 'Kurt', age: 27, lastLogin: moment('2014-11-01'), }, + ]; + + var descriptor = { + columns: [ + { property: 'name', type: 'string' }, + { property: 'age', type: 'number' }, + { property: 'lastLogin', type: 'date', format: 'YYYY MM DD' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('name,age,lastLogin\r\nJohn,40,2014 12 03\r\nKurt,27,2014 11 01\r\n'); + done(); + }); + }); + it('should compute values from function properties', function(done) { + var data = [ + { name: 'John', payments: 400, withdrawals: 300, }, + { name: 'Kurt', payments: 270.5, withdrawals: 200, }, + ]; + + var descriptor = { + columns: [ + { property: 'name', type: 'string' }, + { label: 'Balance', property: function (obj) { return obj.payments - obj.withdrawals; }, type: 'number' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('name,Balance\r\nJohn,100\r\nKurt,70.5\r\n'); + done(); + }); + }); + it('should not fail on error from calculated values', function(done) { + var data = [ + { name: 'John', error: 0 }, + { name: 'Kurt', error: 1 }, + ]; + + var descriptor = { + columns: [{ + property: 'name', + type: 'string' + }, { + label: 'Error', + property: function(obj) { + if (obj.error) { + throw 'dummy error'; + } else { + return 'ok'; + } + } + }, ], + }; + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('name,Error\r\nJohn,ok\r\nKurt,dummy error\r\n'); + done(); + }); + }); + it('should use blank label if label not specified for computed property', function(done) { + var data = [ + { name: 'John', payments: 400, withdrawals: 300, }, + { name: 'Kurt', payments: 270.5, withdrawals: 200, }, + ]; + + var descriptor = { + columns: [ + { property: 'name', type: 'string' }, + { property: function (obj) { return obj.payments - obj.withdrawals; }, type: 'number' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('name,\r\nJohn,100\r\nKurt,70.5\r\n'); + done(); + }); + }); + it('should handle non existent properties', function(done) { + var data = [ + { name: 'John', age: 40 }, + { name: 'Kurt', age: 27 }, + ]; + + var descriptor = { + columns: [ + { property: 'name', type: 'string' }, + { property: 'age', type: 'number' }, + { property: 'lastLogin', type: 'date', format: 'YYYY MM DD' }, + ], + }; + + csv.toCsv(data, descriptor, function (err, res) { + res.should.equal('name,age,lastLogin\r\nJohn,40,\r\nKurt,27,\r\n'); + done(); + }); + }); +}); diff --git a/util/build.js b/util/build.js index 1618dfab0..c381ec5fd 100644 --- a/util/build.js +++ b/util/build.js @@ -87,6 +87,9 @@ var createBundle = function(opts) { b.require('./js/util/log', { expose: '../js/util/log' }); + b.require('./js/util/csv', { + expose: '../js/util/csv' + }); if (!opts.disablePlugins) { b.require('./js/plugins/GoogleDrive', {