diff --git a/cli/develop.js b/cli/develop.js index a60080019..a12766d86 100644 --- a/cli/develop.js +++ b/cli/develop.js @@ -10,6 +10,7 @@ const version = require('../src/version').version; const chalk = require('chalk'); const generateWebpackConfig = require("../webpack.config.js").default; const SUPPRESS = require('argparse').Const.SUPPRESS; +const bodyParser = require('body-parser'); const addParser = (parser) => { const description = `Launch auspice in development mode. @@ -50,6 +51,11 @@ const run = (args) => { process.env.BABEL_ENV = "development"; process.env.BABEL_EXTENSION_PATH = extensionPath; + // parse application/json + app.use(bodyParser.json({limit: '20mb'})); + // parse application/x-www-form-urlencoded + app.use(bodyParser.urlencoded({ extended: false })); + /* Redirects / to webpack-generated index */ app.use((req, res, next) => { if (!/^\/__webpack_hmr|^\/charon|\.[A-Za-z0-9]{1,4}$/.test(req.path)) { diff --git a/cli/server/handleGenomeDbs.js b/cli/server/handleGenomeDbs.js new file mode 100644 index 000000000..b07ec6ef7 --- /dev/null +++ b/cli/server/handleGenomeDbs.js @@ -0,0 +1,157 @@ +const fs = require('fs'); +const path = require("path"); +const {PassThrough} = require('stream'); +const Engine = require('nedb'); +const fasta = require('bionode-fasta'); + +const { promisify } = require('util'); + +const readdir = promisify(fs.readdir); + +/* + All NeDB database files are stored in the subdirectory 'genomeDbs' + at the same level where fasta file and auspice file is located. +*/ +const getDbPath = (fastaPath) => { + const dbRoot = path.join(path.dirname(fastaPath), 'genomeDbs'); + const dbPath = path.join(dbRoot, + path.basename(fastaPath).replace(".fasta", ".db")); + return dbPath; +}; + +/* + @param: ids: an array of fasta sequence ids + @param: dbPath: resolvable path to NeDB database of genome sequences +*/ +const fetchRecords = (ids, dbPath) => + new Promise((resolve, reject) => { + console.log("dbPath: " + dbPath); + const db = new Engine({filename: dbPath, autoload: true}); + if (db) { + console.log("db connected"); + db.find({id: {$in: ids}}, (err, docs) => { + if (err) { + console.log('EE'); + reject(err); + } else if (docs.length === 0) { + console.log("No record found!"); + resolve(docs); + } else { + console.log("records: " + docs.length); + resolve(docs); + } + }); + } + }); + + +/** + return response to a POST of fetching genome sequences by an array of ids + @param {string} datasetPath same as datasetDir when staring auspice +*/ +const getGenomeDB = (datasetsPath) => { + return async (req, res) => { // eslint-disable-line consistent-return + try { + const prefix = req.body.prefix + .replace(/^\//, '') + .replace(/\/$/, '') + .split("/") + .join("_"); + const dbPath = datasetsPath + '/genomeDbs/' + prefix + '.db'; + if (!req.body.ids || req.body.ids.length === 0) { + res.setHeader('Content-Type', 'application/json'); + if (fs.existsSync(dbPath)) { + res.end(JSON.stringify({result: true})); + } else { + res.end(JSON.stringify({result: false})); + } + return; + } + res.setHeader('Content-Type', 'text/plain'); + const db = await fetchRecords(req.body.ids, dbPath); + db.forEach((v) => { + const wrappedSeq = v.seq.match(/.{1,80}/g).join('\n') + '\n'; + res.write('>' + v.id + '\n'); + res.write(wrappedSeq); + }); + res.end(); + } catch (err) { + console.trace(err); + } + }; +}; + +/** + @param {string} dbRoot Path to directory where genome database should be saved + @param {string} fastaPath Path to fasta file to use as input to create database + + Database will overwrite existing database files to avoid duplicates. + TODO: Maybe do something else to prevent unexpected data loss +*/ +const makeDB = (dbRoot, fastaPath) => new Promise((resolve, reject) => { + + process.stdin.setEncoding('utf8'); + + if (!fs.existsSync(dbRoot)) { + fs.mkdirSync(dbRoot); + } + const dbPath = getDbPath(fastaPath); + + if (fs.existsSync(dbPath)) { + fs.unlink(dbPath, () => { console.log(`Overwrote ${dbPath} with new data!`);}); + } + + const processRecord = new PassThrough(); + const db = new Engine({filename: dbPath, autoload: true}); + let rc = 0; + + processRecord.on('data', (rec) => { + const obj = JSON.parse(rec); + const outrec = {id: obj.id, seq: obj.seq, source: fastaPath}; + db.insert(outrec); + rc++; + }); + + processRecord.on('end', () => { + console.log(`Total added: ${rc} seqs to ${dbPath}`); + if (fs.existsSync(dbPath)) { + resolve(); + } else { + reject(`File: ${dbPath} was not created.`); + } + } + ); + const rs = fs.createReadStream(fastaPath); + rs.pipe(fasta()) + .pipe(processRecord); + +}); + +/** + @param {string} path Path to datasetDir so we can create database if corresponding fasta + files exists for aupsice input JSON file +*/ +const prepareDbs = async (localPath) => { + try { + const files = await readdir(localPath); + const v2Files = files.filter((file) => ( + file.endsWith(".fasta") + )); + v2Files.forEach((v) => { + makeDB(localPath, localPath + '/' + v); + }); + + + } catch (err) { + // utils.warn(`Couldn't collect available dataset files (path searched: ${locaPath})`); + // utils.verbose(err); + } +}; + +module.exports = { + fetchRecords, + getDbPath, + makeDB, + prepareDbs, + getGenomeDB +}; diff --git a/cli/server/parseNarrative.js b/cli/server/parseNarrative.js index a132e3201..c7843d185 100644 --- a/cli/server/parseNarrative.js +++ b/cli/server/parseNarrative.js @@ -60,7 +60,7 @@ const makeFrontMatterBlock = (frontMatter) => { markdown.push(`#### License: ${license}`); } } - + const block = new Proxy({}, blockProxyHandler); block.url = frontMatter.dataset; block.contents = markdown.join("\n"); diff --git a/cli/view.js b/cli/view.js index 42d6efebb..ef5e55996 100644 --- a/cli/view.js +++ b/cli/view.js @@ -11,6 +11,7 @@ const utils = require("./utils"); const version = require('../src/version').version; const chalk = require('chalk'); const SUPPRESS = require('argparse').Const.SUPPRESS; +const bodyParser = require('body-parser'); const addParser = (parser) => { @@ -58,12 +59,18 @@ const loadAndAddHandlers = ({app, handlersArg, datasetDir, narrativeDir}) => { .setUpGetDatasetHandler({datasetsPath}); handlers.getNarrative = require("./server/getNarrative") .setUpGetNarrativeHandler({narrativesPath}); + handlers.handleGenomeDbs = require("./server/handleGenomeDbs") + .prepareDbs(datasetsPath); /* use sanitized datasetPath */ + handlers.handleGenomeDbs = require("./server/handleGenomeDbs") + .getGenomeDB(datasetsPath); /* use sanitized datasetPath */ } /* apply handlers */ app.get("/charon/getAvailable", handlers.getAvailable); app.get("/charon/getDataset", handlers.getDataset); app.get("/charon/getNarrative", handlers.getNarrative); + app.get("/charon/getGenomeData", handlers.handleGenomeDbs); + app.post("/charon/getGenomeData", handlers.handleGenomeDbs); app.get("/charon*", (req, res) => { res.statusMessage = "Query unhandled -- " + req.originalUrl; utils.warn(res.statusMessage); @@ -102,7 +109,13 @@ const run = (args) => { const app = express(); app.set('port', process.env.PORT || 4000); app.set('host', process.env.HOST || "localhost"); + // parse application/json + app.use(bodyParser.json({limit: '20mb'})); app.use(compression()); + // parse application/x-www-form-urlencoded + app.use(bodyParser.urlencoded({ extended: false })); + + app.use(nakedRedirect({reverse: true})); /* redirect www.name.org to name.org */ if (args.customBuild) { diff --git a/docs-src/website/siteConfig.js b/docs-src/website/siteConfig.js index 36e4fe4f0..04428abe5 100644 --- a/docs-src/website/siteConfig.js +++ b/docs-src/website/siteConfig.js @@ -17,7 +17,7 @@ const siteConfig = { // Header links in the top nav bar headerLinks: [ - {doc: 'introduction/overview', label: 'Docs'}, + {doc: 'introduction/overview', label: 'Docs'} // {doc: 'tutorial/overview', label: 'Tutorial'} ], diff --git a/docs/js/scrollSpy.js b/docs/js/scrollSpy.js index 0632e6c33..484a5d6f5 100755 --- a/docs/js/scrollSpy.js +++ b/docs/js/scrollSpy.js @@ -18,7 +18,7 @@ // throttle return; } - timer = setTimeout(function() { + timer = setTimeout(function () { timer = null; let activeNavFound = false; const headings = findHeadings(); // toc nav anchors @@ -48,7 +48,7 @@ } else { console.error('Can not find header element', { id: next, - heading, + heading }); } } @@ -68,9 +68,9 @@ document.addEventListener('scroll', onScroll); document.addEventListener('resize', onScroll); - document.addEventListener('DOMContentLoaded', function() { + document.addEventListener('DOMContentLoaded', function () { // Cache the headings once the page has fully loaded. headingsCache = findHeadings(); onScroll(); }); -})(); +}()); diff --git a/package-lock.json b/package-lock.json index bd385b149..7f35a5884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "auspice", - "version": "2.15.0", + "version": "2.16.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3706,6 +3706,11 @@ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==" }, + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, "async-each": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz", @@ -4076,6 +4081,14 @@ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz", "integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==" }, + "binary-search-tree": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/binary-search-tree/-/binary-search-tree-0.2.5.tgz", + "integrity": "sha1-fbs7IQ/coIJFDa0jNMMErzm9x4Q=", + "requires": { + "underscore": "~1.4.4" + } + }, "bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -4090,6 +4103,40 @@ "resolved": "https://registry.npmjs.org/binomial/-/binomial-0.2.0.tgz", "integrity": "sha1-zxhKU4nOOX8mtkxT98k0OZAhyPU=" }, + "bionode-fasta": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/bionode-fasta/-/bionode-fasta-0.5.6.tgz", + "integrity": "sha1-L5jMtgQcWNL+O3sHb1Kdq5mVVB4=", + "requires": { + "concat-stream": "~1.6.0", + "fasta-parser": "0.1.0", + "minimist": "~1.2.0", + "pumpify": "~1.3.5", + "split2": "^2.1.1", + "through2": "~2.0.3" + }, + "dependencies": { + "pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "pumpify": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.3.6.tgz", + "integrity": "sha512-BurGAcvezsINL5US9T9wGHHcLNrG6MCp//ECtxron3vcR+Rfx5Anqq7HbZXNJvFQli8FGVsWCAvywEJFV5Hx/Q==", + "requires": { + "duplexify": "^3.5.3", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + } + } + }, "bl": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz", @@ -6770,6 +6817,57 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=" }, + "fasta-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/fasta-parser/-/fasta-parser-0.1.0.tgz", + "integrity": "sha1-rsgLPvL1DOz5wjradPZI07saRgs=", + "requires": { + "bl": "^0.9.0", + "pumpify": "^1.3.0", + "split": "^0.3.0", + "through2": "~0.6.0" + }, + "dependencies": { + "bl": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/bl/-/bl-0.9.5.tgz", + "integrity": "sha1-wGt5evCF6gC8Unr8jvzxHeIjIFQ=", + "requires": { + "readable-stream": "~1.0.26" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=", + "requires": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + } + } + }, "fb-watchman": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz", @@ -8412,6 +8510,11 @@ "which-pm-runs": "^1.0.0" } }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "import-fresh": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.1.0.tgz", @@ -12394,6 +12497,14 @@ "type-check": "~0.3.2" } }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "requires": { + "immediate": "~3.0.5" + } + }, "linspace": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/linspace/-/linspace-1.0.0.tgz", @@ -12447,6 +12558,14 @@ } } }, + "localforage": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", + "integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==", + "requires": { + "lie": "3.1.1" + } + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -12939,6 +13058,18 @@ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=" }, + "nedb": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/nedb/-/nedb-1.8.0.tgz", + "integrity": "sha1-DjUCzYLABNU1WkPJ5VV3vXvZHYg=", + "requires": { + "async": "0.2.10", + "binary-search-tree": "0.2.5", + "localforage": "^1.3.0", + "mkdirp": "~0.5.1", + "underscore": "~1.4.4" + } + }, "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", @@ -13530,9 +13661,9 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "papaparse": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-4.6.3.tgz", - "integrity": "sha512-LRq7BrHC2kHPBYSD50aKuw/B/dGcg29omyJbKWY3KsYUZU69RKwaBHu13jGmCYBtOc4odsLCrFyk6imfyNubJQ==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.2.0.tgz", + "integrity": "sha512-ylq1wgUSnagU+MKQtNeVqrPhZuMYBvOSL00DHycFTCxownF95gpLAk1HiHdUW77N8yxRq1qHXLdlIPyBSG9NSA==" }, "parallel-transform": { "version": "1.2.0", @@ -16353,7 +16484,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=", - "dev": true, "requires": { "through": "2" } @@ -16366,6 +16496,14 @@ "extend-shallow": "^3.0.0" } }, + "split2": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-2.2.0.tgz", + "integrity": "sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw==", + "requires": { + "through2": "^2.0.2" + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -17114,8 +17252,7 @@ "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", - "dev": true + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "through2": { "version": "2.0.5", @@ -17327,6 +17464,11 @@ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.20.tgz", "integrity": "sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw==" }, + "underscore": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=" + }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz", diff --git a/package.json b/package.json index ff2edcf7a..047511df5 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "repository": "github:nextstrain/auspice", "homepage": "https://www.npmjs.com/package/auspice", "engines": { - "node": "10.8.x", - "npm": "6.2.x" + "node": ">=10.8 <= 13.16", + "npm": ">=6.2.x" }, "bin": { "auspice": "./auspice.js" @@ -54,6 +54,8 @@ "babel-plugin-strip-function-call": "^1.0.2", "babel-plugin-styled-components": "^1.10.0", "binomial": "^0.2.0", + "bionode-fasta": "^0.5.6", + "body-parser": "^1.19.0", "chalk": "^2.4.1", "clean-webpack-plugin": "^3.0.0", "compression": "^1.7.3", @@ -92,9 +94,10 @@ "lodash-webpack-plugin": "^0.11.5", "marked": "^0.7.0", "mousetrap": "^1.6.2", + "nedb": "^1.8.0", "node-fetch": "^2.1.2", "outer-product": "0.0.4", - "papaparse": "^4.3.5", + "papaparse": "^5.2.0", "prettyjson": "^1.2.1", "prop-types": "^15.6.0", "query-string": "^4.2.3", diff --git a/src/components/download/downloadModal.js b/src/components/download/downloadModal.js index 545fc76c0..87041e1bf 100644 --- a/src/components/download/downloadModal.js +++ b/src/components/download/downloadModal.js @@ -13,6 +13,8 @@ import { getAcknowledgments} from "../framework/footer"; import { createSummary, getNumSelectedTips } from "../info/info"; import { getFullAuthorInfoFromNode } from "../../util/treeMiscHelpers"; +import { getServerAddress } from "../../util/globals"; + const RectangularTreeIcon = withTheme(icons.RectangularTree); const PanelsGridIcon = withTheme(icons.PanelsGrid); const MetaIcon = withTheme(icons.Meta); @@ -61,7 +63,8 @@ export const publications = { filters: state.controls.filters, visibility: state.tree.visibility, panelsToDisplay: state.controls.panelsToDisplay, - panelLayout: state.controls.panelLayout + panelLayout: state.controls.panelLayout, + isGenomeAvailable: state.controls.isGenomeAvailable })) class DownloadModal extends React.Component { constructor(props) { @@ -109,6 +112,17 @@ class DownloadModal extends React.Component { }; }; this.dismissModal = this.dismissModal.bind(this); + const path = `${getServerAddress()}/getGenomeData`; + this.state = {isGenomeAvailable: false}; + fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ids: [], prefix: window.location.pathname})}) + .then((res) => { + if (res.status !== 200) { + throw new Error(res.statusText); + } + res.json().then((json) => { + this.setState({isGenomeAvailable: json.result}); + }); + }); } componentDidMount() { Mousetrap.bind('d', () => { @@ -140,9 +154,9 @@ class DownloadModal extends React.Component { getFilePrefix() { return "nextstrain_" + window.location.pathname - .replace(/^\//, '') // Remove leading slashes - .replace(/:/g, '-') // Change ha:na to ha-na - .replace(/\//g, '_'); // Replace slashes with spaces + .replace(/^\//, '') // Remove leading slashes + .replace(/:/g, '-') // Change ha:na to ha-na + .replace(/\//g, '_'); // Replace slashes with spaces } makeTextStringsForSVGExport() { const x = []; @@ -162,7 +176,7 @@ class DownloadModal extends React.Component { } getNumUniqueAuthors(nodes) { const authors = nodes.map((n) => getFullAuthorInfoFromNode(n)) - .filter((a) => a && a.value); + .filter((a) => a && a.value); const uniqueAuthors = new Set(authors.map((a) => a.value)); return uniqueAuthors.size; } @@ -188,6 +202,11 @@ class DownloadModal extends React.Component { buttons.push(["Selected Metadata (TSV)", `Per-sample metadata for strains which are currently displayed (n = ${selectedTipsCount}/${this.props.metadata.mainTreeNumTips}).`, (), () => helpers.strainTSV(this.props.dispatch, filePrefix, this.props.nodes, this.props.metadata.colorings, true, this.props.tree.visibility)]); + if (this.state.isGenomeAvailable) { + buttons.push(["Selected Genomes (fasta)", `Per-sample genome for strains which are currently displayed (n = ${selectedTipsCount}/${this.props.metadata.mainTreeNumTips}).`, + (), () => helpers.strainGenome(this.props.dispatch, filePrefix, this.props.nodes, + this.props.metadata.colorings, true, this.props.tree.visibility)]); + } } if (helpers.areAuthorsPresent(this.props.tree)) { buttons.push(["Author Metadata (TSV)", `Metadata for all samples in the dataset (n = ${this.props.metadata.mainTreeNumTips}) grouped by their ${uniqueAuthorCount} authors.`, @@ -263,7 +282,7 @@ class DownloadModal extends React.Component {
stopProp(e)}>

- ({t("click outside this box to return to the app")}) + ({t("click outside this box to return to the app")})

diff --git a/src/components/download/helperFunctions.js b/src/components/download/helperFunctions.js index 5d020f104..8671d75f0 100644 --- a/src/components/download/helperFunctions.js +++ b/src/components/download/helperFunctions.js @@ -3,13 +3,13 @@ import { infoNotification, warningNotification } from "../../actions/notificatio import { spaceBetweenTrees } from "../tree/tree"; import { getTraitFromNode, getDivFromNode, getFullAuthorInfoFromNode, getVaccineFromNode, getAccessionFromNode } from "../../util/treeMiscHelpers"; import { numericToCalendar } from "../../util/dateHelpers"; -import { NODE_VISIBLE } from "../../util/globals"; +import { NODE_VISIBLE, getServerAddress } from "../../util/globals"; export const isPaperURLValid = (d) => { return ( Object.prototype.hasOwnProperty.call(d, "paper_url") && - !d.paper_url.endsWith('/') && - d.paper_url !== "?" + !d.paper_url.endsWith('/') && + d.paper_url !== "?" ); }; @@ -109,7 +109,7 @@ export const authorTSV = (dispatch, filePrefix, tree) => { export const strainTSV = (dispatch, filePrefix, nodes, colorings, selectedNodesOnly, nodeVisibilities) => { /* traverse the tree & store tip information. We cannot write this out as we go as we don't know - exactly which header fields we want until the tree has been traversed. */ + exactly which header fields we want until the tree has been traversed. */ const tipTraitValues = {}; const headerFields = ["Strain"]; @@ -117,13 +117,13 @@ export const strainTSV = (dispatch, filePrefix, nodes, colorings, selectedNodesO if (node.hasChildren) continue; /* we only consider tips */ if (selectedNodesOnly && nodeVisibilities && - (nodeVisibilities[i] !== NODE_VISIBLE || !node.inView)) {continue;} /* skip unselected nodes if requested */ + (nodeVisibilities[i] !== NODE_VISIBLE || !node.inView)) {continue;} /* skip unselected nodes if requested */ tipTraitValues[node.name] = {Strain: node.name}; if (!node.node_attrs) continue; /* if this is not set then we don't have any node info! */ /* collect values (as writable strings) of the same "traits" as can be viewed by the modal displayed - when clicking on tips. Note that "num_date", "author" and "vaccine" are considered seperately below */ + when clicking on tips. Note that "num_date", "author" and "vaccine" are considered seperately below */ const nodeAttrsToIgnore = ["author", "div", "num_date", "vaccine", "accession"]; const traits = Object.keys(node.node_attrs).filter((k) => !nodeAttrsToIgnore.includes(k)); for (const trait of traits) { @@ -199,6 +199,34 @@ export const strainTSV = (dispatch, filePrefix, nodes, colorings, selectedNodesO dispatch(infoNotification({message: `Metadata exported to ${filename}`})); }; +/** + * Create & write a FASTA file containing genome sequences of strains in the tree + */ +export const strainGenome = (dispatch, filePrefix, nodes, colorings, selectedNodesOnly, nodeVisibilities) => { + const tipGenomes = []; + + for (const [i, node] of nodes.entries()) { + if (node.hasChildren) continue; /* we only consider tips */ + + if (selectedNodesOnly && nodeVisibilities && + (nodeVisibilities[i] !== NODE_VISIBLE || !node.inView)) {continue;} /* skip unselected nodes if requested */ + tipGenomes.push(node.name); + + } + const path = `${getServerAddress()}/getGenomeData`; + fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ids: tipGenomes, prefix: window.location.pathname})}) + .then((res) => { + if (res.status !== 200) { + throw new Error(res.statusText); + } + res.text().then((body) => { + const filename = `${filePrefix}${selectedNodesOnly ? "_selected_" : "_"}genomes.fasta`; + write(filename, MIME.text, body); + dispatch(infoNotification({message: `Genomes exported to ${filename}`})); + }); + }); +}; + export const newick = (dispatch, filePrefix, root, temporal) => { const fName = temporal ? filePrefix + "_timetree.nwk" : filePrefix + "_tree.nwk"; const message = temporal ? "TimeTree" : "Tree"; @@ -228,7 +256,7 @@ const processXMLString = (input) => { }; /* take the panels (see processXMLString for struct) and calculate the overall size of the SVG -as well as the offsets (x, y) to position panels appropriately within this */ + as well as the offsets (x, y) to position panels appropriately within this */ const createBoundingDimensionsAndPositionPanels = (panels, panelLayout, numLinesOfText) => { const padding = 50; let width = 0; diff --git a/src/reducers/controls.js b/src/reducers/controls.js index 1e2c756c6..d5c12e3d8 100644 --- a/src/reducers/controls.js +++ b/src/reducers/controls.js @@ -12,7 +12,7 @@ import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; /* defaultState is a fn so that we can re-create it -at any time, e.g. if we want to revert things (e.g. on dataset change) + at any time, e.g. if we want to revert things (e.g. on dataset change) */ export const getDefaultControlsState = () => { const defaults = { @@ -43,6 +43,8 @@ export const getDefaultControlsState = () => { region: null, search: null, strain: null, + gridFiltered: null, + isGenomeAvailable: false, geneLength: {}, mutType: defaultMutType, temporalConfidence: {exists: false, display: false, on: false}, @@ -201,7 +203,7 @@ const Controls = (state = getDefaultControlsState(), action) => { geoResolution: action.data }); case types.APPLY_FILTER: { - // values arrive as array + // values arrive as array const filters = Object.assign({}, state.filters, {}); filters[action.trait] = action.values; return Object.assign({}, state, { @@ -249,6 +251,10 @@ const Controls = (state = getDefaultControlsState(), action) => { return Object.assign({}, state, {coloringsPresentOnTree: state.coloringsPresentOnTree}); case types.TOGGLE_TRANSMISSION_LINES: return Object.assign({}, state, {showTransmissionLines: action.data}); + case 'GRID_FILTERED': + return Object.assign({}, state, {gridFiltered: action.data}); + case 'GENOME_AVAILBLE': + return Object.assign({}, state, {isGenomeAvailable: action.data}); default: return state; } @@ -258,7 +264,7 @@ export default Controls; function getInitialSidebarState() { /* The following "hack" was present when `sidebarOpen` wasn't URL customisable. It can be removed - from here once the GISAID URLs (iFrames) are updated */ + from here once the GISAID URLs (iFrames) are updated */ if (window.location.pathname.includes("gisaid")) { return {sidebarOpen: false, setDefault: true}; }