From b426d134d4f6c781996131e277c09beb17b48939 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 14 Oct 2024 17:48:45 +0200 Subject: [PATCH 1/7] implement writeToString for CSV formatters, with basic tests --- .npmrc | 2 + config/setupTestEnv.js | 5 +-- .../csv/__tests__/attribute-list-test.js | 17 ++++++++ .../csv/__tests__/edge-list-test.js | 40 ++++++++++++++++++- src/formatters/csv/__tests__/ego-list-test.js | 20 +++++++++- src/formatters/csv/attribute-list.js | 27 +++++++++++++ src/formatters/csv/edge-list.js | 32 +++++++++++++++ src/formatters/csv/ego-list.js | 30 ++++++++++++++ 8 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 .npmrc 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/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..d6cda90 100644 --- a/src/formatters/csv/__tests__/edge-list-test.js +++ b/src/formatters/csv/__tests__/edge-list-test.js @@ -12,7 +12,7 @@ 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 +76,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..17f923f 100644 --- a/src/formatters/csv/__tests__/ego-list-test.js +++ b/src/formatters/csv/__tests__/ego-list-test.js @@ -1,7 +1,7 @@ /* 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 +52,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..bf2903c 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) || []; @@ -142,4 +171,5 @@ module.exports = { EgoListFormatter, asEgoAndSessionVariablesList, toCSVStream, + toCSVString, }; From cfd0ad845303c565c57c465a6bc9e8124ca40d19 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 14 Oct 2024 17:55:09 +0200 Subject: [PATCH 2/7] remove test --- src/exportFile.js | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) 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 From e98a2510abcf2dc4d3954d148dfc108111a7848c Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Mon, 14 Oct 2024 18:02:10 +0200 Subject: [PATCH 3/7] correctly resolve from shareWithOptions --- src/utils/general.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } }); From 2dbd2ecca97c9866c829c29de15a2adce9b0d4cd Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 15 Oct 2024 12:11:38 +0200 Subject: [PATCH 4/7] linting --- package-lock.json | 11 +++++++---- src/formatters/csv/__tests__/edge-list-test.js | 7 ++++++- src/formatters/csv/__tests__/ego-list-test.js | 7 ++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6de436b..54f6bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5097,7 +5097,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001566", + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", "dev": true, "funding": [ { @@ -5112,8 +5114,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/capture-exit": { "version": "2.0.0", @@ -18591,7 +18592,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001566", + "version": "1.0.30001668", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001668.tgz", + "integrity": "sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==", "dev": true }, "capture-exit": { diff --git a/src/formatters/csv/__tests__/edge-list-test.js b/src/formatters/csv/__tests__/edge-list-test.js index d6cda90..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, toCSVString } from '../edge-list'; +import { + EdgeListFormatter, + asEdgeList, + toCSVStream, + toCSVString, +} from '../edge-list'; const nodes = [ { [entityPrimaryKeyProperty]: 1 }, diff --git a/src/formatters/csv/__tests__/ego-list-test.js b/src/formatters/csv/__tests__/ego-list-test.js index 17f923f..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, toCSVString } from '../ego-list'; +import { + EgoListFormatter, + asEgoAndSessionVariablesList, + toCSVStream, + toCSVString, +} from '../ego-list'; import { entityPrimaryKeyProperty, entityAttributesProperty, From bd964415b53bb9dcdcc21d300c5e3655afdec68e Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 15 Oct 2024 13:56:10 +0200 Subject: [PATCH 5/7] add test for GraphML string export; change GraphML implementation of writeToString to match CSV --- src/formatters/graphml/GraphMLFormatter.js | 31 +++++------------ .../__tests__/GraphMLFormatter-test.js | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 23 deletions(-) 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..d38a601 100644 --- a/src/formatters/graphml/__tests__/GraphMLFormatter-test.js +++ b/src/formatters/graphml/__tests__/GraphMLFormatter-test.js @@ -3,6 +3,7 @@ import { Writable } from 'stream'; import { mockNetwork, mockCodebook } from '../../../../config/mockObjects'; import GraphMLFormatter from '../GraphMLFormatter'; +import { DOMParser } from '@xmldom/xmldom'; const makeWriteableStream = () => { const chunks = []; @@ -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; From d61202c997d0040e63cc87d32508ecfae24a1288 Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Tue, 15 Oct 2024 14:30:42 +0200 Subject: [PATCH 6/7] linting --- src/formatters/graphml/__tests__/GraphMLFormatter-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/formatters/graphml/__tests__/GraphMLFormatter-test.js b/src/formatters/graphml/__tests__/GraphMLFormatter-test.js index d38a601..7a26b8b 100644 --- a/src/formatters/graphml/__tests__/GraphMLFormatter-test.js +++ b/src/formatters/graphml/__tests__/GraphMLFormatter-test.js @@ -1,9 +1,9 @@ /* eslint-env jest */ +import { DOMParser } from '@xmldom/xmldom'; import { Writable } from 'stream'; import { mockNetwork, mockCodebook } from '../../../../config/mockObjects'; import GraphMLFormatter from '../GraphMLFormatter'; -import { DOMParser } from '@xmldom/xmldom'; const makeWriteableStream = () => { const chunks = []; From 5bf3f96a0694acc8ef332bc9d3dc409fbee483fc Mon Sep 17 00:00:00 2001 From: Joshua Melville Date: Wed, 16 Oct 2024 10:43:03 +0200 Subject: [PATCH 7/7] add missing writeToString to ego-list formatter class --- src/formatters/csv/ego-list.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/formatters/csv/ego-list.js b/src/formatters/csv/ego-list.js index bf2903c..f422472 100644 --- a/src/formatters/csv/ego-list.js +++ b/src/formatters/csv/ego-list.js @@ -165,6 +165,10 @@ class EgoListFormatter { writeToStream(outStream) { return toCSVStream(this.list, outStream); } + + writeToString() { + return toCSVString(this.list); + } } module.exports = {