diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..5a89ce1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-prefix=~ +arch=x64 \ No newline at end of file diff --git a/config/setupTestEnv.js b/config/setupTestEnv.js index 902e967..88f8c88 100644 --- a/config/setupTestEnv.js +++ b/config/setupTestEnv.js @@ -2,12 +2,9 @@ // Cert verification is disabled during tests for now to work around // https://github.com/nodejs/node/issues/14736; fixed with Node 8.10.0. -const http = require('http'); -const https = require('https'); const enzyme = require('enzyme'); const Adapter = require('enzyme-adapter-react-16'); -const url = require('url'); -const Writable = require('stream').Writable; +const { Writable } = require('stream'); enzyme.configure({ adapter: new Adapter() }); diff --git a/src/exportFile.js b/src/exportFile.js index 22290b8..ccd1c8b 100644 --- a/src/exportFile.js +++ b/src/exportFile.js @@ -1,5 +1,5 @@ /* eslint-disable global-require */ -const { createWriteStream } = require('./utils/filesystem'); +const { createWriteStream, writeFile } = require('./utils/filesystem'); const { getFileExtension, makeFilename, @@ -50,31 +50,41 @@ const exportFile = ( const pathPromise = new Promise((resolve, reject) => { promiseResolve = resolve; promiseReject = reject; - let filePath; const formatter = new Formatter(network, codebook, exportOptions); const outputName = makeFilename(namePrefix, partitonedEntityName, exportFormat, extension); if (isElectron()) { const path = require('path'); - filePath = path.join(outDir, outputName); + const filePath = path.join(outDir, outputName); + + createWriteStream(filePath) + .then((ws) => { + writeStream = ws; + writeStream.on('finish', () => { + promiseResolve(filePath); + }); + writeStream.on('error', (err) => { + promiseReject(err); + }); + + streamController = formatter.writeToStream(writeStream); + }); } + // We encountered a bug with Cordova where CSV files where sometimes empty. + // As a precaution, we switched to writing the file in one go. if (isCordova()) { - filePath = `${outDir}${outputName}`; - } + const filePath = `${outDir}${outputName}`; + const string = formatter.writeToString(); - createWriteStream(filePath) - .then((ws) => { - writeStream = ws; - writeStream.on('finish', () => { + writeFile(filePath, string) + .then(() => { promiseResolve(filePath); - }); - writeStream.on('error', (err) => { + }) + .catch((err) => { promiseReject(err); }); - - streamController = formatter.writeToStream(writeStream); - }); + } }); // Decorate the promise with an abort method that also tears down the diff --git a/src/formatters/csv/__tests__/attribute-list-test.js b/src/formatters/csv/__tests__/attribute-list-test.js index c74eef7..5f8e102 100644 --- a/src/formatters/csv/__tests__/attribute-list-test.js +++ b/src/formatters/csv/__tests__/attribute-list-test.js @@ -32,6 +32,23 @@ describe('asAttributeList', () => { }); }); +describe('writeToString', () => { + it('writes a simple CSV', () => { + const formatter = new AttributeListFormatter( + { nodes: [node] }, mockCodebook, mockExportOptions, + ); + const csv = formatter.writeToString(); + const result = [ + ...baseCSVAttributes, + 'name\r\n', + 123, + 1, + 'Jane\r\n', + ].join(','); + expect(csv).toEqual(result); + }); +}); + describe('toCSVStream', () => { let writable; let testNode; diff --git a/src/formatters/csv/__tests__/edge-list-test.js b/src/formatters/csv/__tests__/edge-list-test.js index c6610b4..c3c82fb 100644 --- a/src/formatters/csv/__tests__/edge-list-test.js +++ b/src/formatters/csv/__tests__/edge-list-test.js @@ -12,7 +12,12 @@ import { ncSourceUUID, ncTargetUUID, } from '../../../utils/reservedAttributes'; -import { EdgeListFormatter, asEdgeList, toCSVStream } from '../edge-list'; +import { + EdgeListFormatter, + asEdgeList, + toCSVStream, + toCSVString, +} from '../edge-list'; const nodes = [ { [entityPrimaryKeyProperty]: 1 }, @@ -76,6 +81,44 @@ describe('asEdgeList', () => { }); }); +describe('toCSVString', () => { + it('Writes a csv with attributes', () => { + const list = listFromEdges([ + { + [entityPrimaryKeyProperty]: 123, + [egoProperty]: 456, + [edgeExportIDProperty]: 1, + [ncSourceUUID]: 1, + [ncTargetUUID]: 2, + [edgeSourceProperty]: 1, + [edgeTargetProperty]: 2, + [entityAttributesProperty]: { + a: 1, + }, + }, + ]); + const csv = toCSVString(list); + const result = [ + edgeExportIDProperty, + edgeSourceProperty, + edgeTargetProperty, + egoProperty, + ncUUIDProperty, + ncSourceUUID, + ncTargetUUID, + 'a\r\n1', + 1, + 2, + 456, + 123, + 1, + 2, + '1\r\n', + ].join(','); + expect(csv).toEqual(result); + }); +}); + describe('toCSVStream', () => { let writable; diff --git a/src/formatters/csv/__tests__/ego-list-test.js b/src/formatters/csv/__tests__/ego-list-test.js index cd21349..92b1f16 100644 --- a/src/formatters/csv/__tests__/ego-list-test.js +++ b/src/formatters/csv/__tests__/ego-list-test.js @@ -1,7 +1,12 @@ /* eslint-env jest */ import { makeWriteableStream } from '../../../../config/setupTestEnv'; import { mockCodebook, mockExportOptions } from '../../../../config/mockObjects'; -import { EgoListFormatter, asEgoAndSessionVariablesList, toCSVStream } from '../ego-list'; +import { + EgoListFormatter, + asEgoAndSessionVariablesList, + toCSVStream, + toCSVString, +} from '../ego-list'; import { entityPrimaryKeyProperty, entityAttributesProperty, @@ -52,6 +57,24 @@ describe('asEgoAndSessionVariablesList', () => { }); }); +describe('toCSVString', () => { + it('writes a simple CSV', () => { + const csv = toCSVString([ego]); + const result = [ + ...baseCSVAttributes, + 'name\r\n1', + 'case id', + 789, + 'protocol name', + 100, + 200, + 300, + 'Jane\r\n', + ].join(','); + expect(csv).toEqual(result); + }); +}); + describe('toCSVStream', () => { let writable; let testEgo; diff --git a/src/formatters/csv/attribute-list.js b/src/formatters/csv/attribute-list.js index 23804e1..2beee7b 100644 --- a/src/formatters/csv/attribute-list.js +++ b/src/formatters/csv/attribute-list.js @@ -98,6 +98,29 @@ const toCSVStream = (nodes, outStream) => { }; }; +const toCSVString = (nodes) => { + const attrNames = attributeHeaders(nodes); + const headerValue = `${attrNames.map((attr) => sanitizedCellValue(getPrintableAttribute(attr))).join(',')}${csvEOL}`; + const rows = nodes.map((node) => { + const values = attrNames.map((attrName) => { + // The primary key and ego id exist at the top-level; all others inside `.attributes` + let value; + if ( + attrName === entityPrimaryKeyProperty + || attrName === egoProperty + || attrName === nodeExportIDProperty + ) { + value = node[attrName]; + } else { + value = node[entityAttributesProperty][attrName]; + } + return sanitizedCellValue(value); + }); + return `${values.join(',')}${csvEOL}`; + }); + return headerValue + rows.join(''); +}; + class AttributeListFormatter { constructor(data, codebook, exportOptions) { this.list = asAttributeList(data, codebook, exportOptions); @@ -106,6 +129,10 @@ class AttributeListFormatter { writeToStream(outStream) { return toCSVStream(this.list, outStream); } + + writeToString() { + return toCSVString(this.list); + } } module.exports = { diff --git a/src/formatters/csv/edge-list.js b/src/formatters/csv/edge-list.js index 4819589..ea72c91 100644 --- a/src/formatters/csv/edge-list.js +++ b/src/formatters/csv/edge-list.js @@ -146,6 +146,33 @@ const toCSVStream = (edges, outStream) => { abort: () => { inStream.destroy(); }, }; }; + +const toCSVString = (edges) => { + const attrNames = attributeHeaders(edges); + const headerValue = `${attrNames.map((attr) => sanitizedCellValue(getPrintableAttribute(attr))).join(',')}${csvEOL}`; + const rows = edges.map((edge) => { + const values = attrNames.map((attrName) => { + // primary key/ego id/to/from exist at the top-level; all others inside `.attributes` + let value; + if ( + attrName === entityPrimaryKeyProperty + || attrName === edgeExportIDProperty + || attrName === egoProperty + || attrName === 'to' + || attrName === 'from' + || attrName === ncSourceUUID + || attrName === ncTargetUUID + ) { + value = edge[attrName]; + } else { + value = edge[entityAttributesProperty][attrName]; + } + return sanitizedCellValue(value); + }); + return `${values.join(',')}${csvEOL}`; + }); + return headerValue + rows.join(''); +}; class EdgeListFormatter { constructor(data, codebook, exportOptions) { this.list = asEdgeList(data, codebook, exportOptions); @@ -154,10 +181,15 @@ class EdgeListFormatter { writeToStream(outStream) { return toCSVStream(this.list, outStream); } + + writeToString() { + return toCSVString(this.list); + } } module.exports = { EdgeListFormatter, asEdgeList, toCSVStream, + toCSVString, }; diff --git a/src/formatters/csv/ego-list.js b/src/formatters/csv/ego-list.js index 39616c8..f422472 100644 --- a/src/formatters/csv/ego-list.js +++ b/src/formatters/csv/ego-list.js @@ -128,6 +128,35 @@ const toCSVStream = (egos, outStream) => { }; }; +const toCSVString = (egos) => { + const attrNames = attributeHeaders(egos); + const headerValue = `${attrNames.map((attr) => sanitizedCellValue(getPrintableAttribute(attr))).join(',')}${csvEOL}`; + + const rows = egos.map((ego) => { + const values = attrNames.map((attrName) => { + // Session variables exist at the top level - all others inside `attributes` + let value; + if ( + attrName === entityPrimaryKeyProperty + || attrName === caseProperty + || attrName === sessionProperty + || attrName === protocolName + || attrName === sessionStartTimeProperty + || attrName === sessionFinishTimeProperty + || attrName === sessionExportTimeProperty + ) { + value = ego[attrName]; + } else { + value = ego[entityAttributesProperty][attrName]; + } + return sanitizedCellValue(value); + }); + return `${values.join(',')}${csvEOL}`; + }); + + return headerValue + rows.join(''); +}; + class EgoListFormatter { constructor(network, codebook, exportOptions) { this.list = asEgoAndSessionVariablesList(network, codebook, exportOptions) || []; @@ -136,10 +165,15 @@ class EgoListFormatter { writeToStream(outStream) { return toCSVStream(this.list, outStream); } + + writeToString() { + return toCSVString(this.list); + } } module.exports = { EgoListFormatter, asEgoAndSessionVariablesList, toCSVStream, + toCSVString, }; diff --git a/src/formatters/graphml/GraphMLFormatter.js b/src/formatters/graphml/GraphMLFormatter.js index 2849420..91f1989 100644 --- a/src/formatters/graphml/GraphMLFormatter.js +++ b/src/formatters/graphml/GraphMLFormatter.js @@ -15,18 +15,6 @@ class GraphMLFormatter { this.exportOptions = exportOptions; } - streamToString = (stream) => { - const chunks = []; - return new Promise((resolve, reject) => { - stream.on('data', (chunk) => chunks.push(chunk)); - stream.on('error', reject); - stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); - }); - } - - /** - * A method allowing writing the file to a string. Used for tests. - */ writeToString() { const generator = graphMLGenerator( this.network, @@ -34,18 +22,14 @@ class GraphMLFormatter { this.exportOptions, ); - const inStream = new Readable({ - read(/* size */) { - const { done, value } = generator.next(); - if (done) { - this.push(null); - } else { - this.push(value); - } - }, - }); + const chunks = []; - return this.streamToString(inStream); + // Call the generator until it is done + for (let { done, value } = generator.next(); !done; { done, value } = generator.next()) { + chunks.push(value); + } + + return chunks.join(''); } /** @@ -58,6 +42,7 @@ class GraphMLFormatter { this.codebook, this.exportOptions, ); + const inStream = new Readable({ read(/* size */) { const { done, value } = generator.next(); diff --git a/src/formatters/graphml/__tests__/GraphMLFormatter-test.js b/src/formatters/graphml/__tests__/GraphMLFormatter-test.js index 892acaa..7a26b8b 100644 --- a/src/formatters/graphml/__tests__/GraphMLFormatter-test.js +++ b/src/formatters/graphml/__tests__/GraphMLFormatter-test.js @@ -1,5 +1,6 @@ /* eslint-env jest */ +import { DOMParser } from '@xmldom/xmldom'; import { Writable } from 'stream'; import { mockNetwork, mockCodebook } from '../../../../config/mockObjects'; import GraphMLFormatter from '../GraphMLFormatter'; @@ -22,6 +23,38 @@ const makeWriteableStream = () => { return writable; }; +describe('GraphMLFormatter writeToString', () => { + let network; + let codebook; + let exportOptions; + + beforeEach(() => { + network = mockNetwork; + codebook = mockCodebook; + exportOptions = { + exportGraphML: true, + exportCSV: false, + globalOptions: { + resequenceIDs: false, + unifyNetworks: false, + useDirectedEdges: false, + }, + }; + }); + + it('produces valid XML', () => { + const formatter = new GraphMLFormatter(network, codebook, exportOptions); + const xml = formatter.writeToString(); + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xml, 'text/xml'); + + expect(xmlDoc.documentElement.nodeName).toBe('graphml'); + expect(xmlDoc.documentElement.getElementsByTagName('graph').length).toBe(1); + expect(xmlDoc.documentElement.getElementsByTagName('node').length).toBe(4); + expect(xmlDoc.documentElement.getElementsByTagName('edge').length).toBe(1); + }); +}); + describe('GraphMLFormatter writeToStream', () => { let network; let codebook; diff --git a/src/utils/general.js b/src/utils/general.js index 90ab918..243aff1 100644 --- a/src/utils/general.js +++ b/src/utils/general.js @@ -139,7 +139,7 @@ const handlePlatformSaveDialog = (zipLocation, filename) => new Promise((resolve // subject: 'network canvas export', files: [zipLocation], chooserTitle: 'Share zip file via', // Android only - }, resolve, reject); + }, () => resolve(null), reject); } });