Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Open Tree of Life resolution #233

Merged
merged 9 commits into from
Apr 27, 2022
3 changes: 3 additions & 0 deletions src/components/citations/Citation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
<option value="book_section">
Book section
</option>
<option value="misc">
Miscellaneous
</option>
</select>
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions src/components/sidebar/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,13 @@
>
<em>Add phylogeny</em>
</a>
<a
class="list-group-item list-group-item-action"
href="javascript: void(0)"
@click="$store.dispatch('createPhylogenyFromOpenTree')"
>
<em>Add Open Tree of Life phylogeny</em>
</a>
</div>
</div>
</div><!-- End of sidebar -->
Expand Down
14 changes: 14 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,18 @@ module.exports = {

// X-Hub-Signature secret used to communicate with JPhyloRef.
JPHYLOREF_X_HUB_SIGNATURE_SECRET: 'undefined',

/* Open Tree of Life API endpoints used by Klados */
// URL to retrieve information about the Open Tree of Life
// (https://github.com/OpenTreeOfLife/germinator/wiki/Synthetic-tree-API-v3#about_tree)
OPEN_TREE_ABOUT_URL: 'https://api.opentreeoflife.org/v3/tree_of_life/about',

// URL to retrieve Open Tree of Life Taxonomy IDs corresponding the given taxon names
// (https://github.com/OpenTreeOfLife/germinator/wiki/TNRS-API-v3#match_names)
OPEN_TREE_TNRS_MATCH_NAMES_URL: 'https://api.opentreeoflife.org/v3/tnrs/match_names',

// URL to retrieve the Open Tree of Life synthetic tree induced subtree containing particular
// Open Tree Taxonomy IDs
// (https://github.com/OpenTreeOfLife/germinator/wiki/Synthetic-tree-API-v3#induced_subtree)
OPEN_TREE_INDUCED_SUBTREE_URL: 'https://api.opentreeoflife.org/v3/tree_of_life/induced_subtree',
};
177 changes: 175 additions & 2 deletions src/store/modules/phyx.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@
*/

import Vue from 'vue';
import { TaxonNameWrapper } from '@phyloref/phyx';
import { has, cloneDeep, isEqual } from 'lodash';
import jQuery from 'jquery';
import { TaxonNameWrapper, PhylorefWrapper, TaxonConceptWrapper } from '@phyloref/phyx';
import { has, cloneDeep, isEqual, keys } from 'lodash';

// Get some configuration settings.
import {
OPEN_TREE_ABOUT_URL,
OPEN_TREE_TNRS_MATCH_NAMES_URL,
OPEN_TREE_INDUCED_SUBTREE_URL,
} from '../../config';

export default {
state: {
Expand Down Expand Up @@ -63,6 +71,10 @@ export default {
// Create a new, empty phylogeny.
state.currentPhyx.phylogenies.push({});
},
createPhylogeny(state, { phylogeny }) {
// Create a new phylogeny based on a particular template.
state.currentPhyx.phylogenies.push(phylogeny);
},
deletePhyloref(state, payload) {
// Delete a phyloreference.
if (!has(payload, 'phyloref')) {
Expand Down Expand Up @@ -108,4 +120,165 @@ export default {
if (has(payload, 'orcid')) Vue.set(state.currentPhyx, 'curatorORCID', payload.orcid);
},
},
actions: {
createPhylogenyFromOpenTree({ commit, state }) {
// Create a new, empty phylogeny from the Open Tree of Life using the /induced_subtree endpoint
// at https://github.com/OpenTreeOfLife/germinator/wiki/Synthetic-tree-API-v3#induced_subtree

function createOTTPhylogenyWithCitation(phylogeny) {
// Create an OTT phylogeny after querying for the synthetic tree ID.
jQuery.ajax({
type: 'POST',
url: OPEN_TREE_ABOUT_URL,
dataType: 'json',
error: err => console.log('Could not retrieve Open Tree of Life /about information: ', err),
success: (data) => {
// Citation as per https://tree.opentreeoflife.org/about/open-tree-of-life, retrieved March 30, 2022.
const citation = {
type: 'misc',
authors: [
{
name: 'OpenTree et al',
},
],
year: data.date_created.substring(0, 4) || new Date().getFullYear(),
title: `Open Tree of Life synthetic tree ${data.synth_id || '(unknown synthetic tree version)'} using taxonomy ${data.taxonomy_version || '(unknown taxonomy version)'}`,
link: [
{
url: 'https://doi.org/10.5281/zenodo.3937741',
},
],
};

// Add the citation to the provided phylogeny.
const citedPhylogeny = {
phylogenyCitation: citation,
...phylogeny,
};

// Now create this phylogeny via Vue.
commit('createPhylogeny', { phylogeny: citedPhylogeny });
},
});
}

// Step 1. Get a list of all taxon names used across all phyloreferences.
const taxonConceptNames = (state.currentPhyx.phylorefs || [])
.map(phyloref => new PhylorefWrapper(phyloref))
.flatMap(wrappedPhyloref => wrappedPhyloref.specifiers)
.map(specifier => new TaxonConceptWrapper(specifier))
.map(wrappedTC => wrappedTC.nameComplete)
.filter(name => name); // Eliminate blank and undefined names

// Note that we don't assume that these names are at any particular taxonomic level;
// thus, if "Amphibia" is used as a specifier, we will include Amphibia in the generated
// phylogeny.

if (taxonConceptNames.length === 0) {
// If no taxon names are used in any phyloreferences -- if they use non-taxon-name
// identifiers or if there are no phyloreferences, say -- we create a new phylogeny
// named "Open Tree of Life" with a description that tells the user what happened.
createOTTPhylogenyWithCitation({
label: 'Open Tree of Life',
description: 'Attempt to load Open Tree of Life tree failed: no taxon name specifiers present.',
newick: '()',
});
return;
}

// Step 2. Convert the list of taxonomic names into a list of OTT IDs.
const namesToQuery = Array.from(new Set(taxonConceptNames));

// The following method sometimes returns a Promise and sometimes returns nothing,
// so it's not a consistent return.
// eslint-disable-next-line consistent-return
return jQuery.ajax({
type: 'POST',
url: OPEN_TREE_TNRS_MATCH_NAMES_URL,
data: JSON.stringify({ names: namesToQuery }),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
error: err => console.log('Error accessing Open Tree Taxonomy match_names: ', err),
success: (data) => {
// Go through the `matches` in the returned `results` and pull out the OTT IDs.
const ottIds = (data.results || [])
.flatMap(r => (r.matches || [])
.flatMap((m) => {
if ('taxon' in m && 'ott_id' in m.taxon) return [m.taxon.ott_id];
return [];
}));

// Try to retrieve the induced subtree including these OTT IDs.
return jQuery.ajax({
type: 'POST',
url: OPEN_TREE_INDUCED_SUBTREE_URL,
data: JSON.stringify({
ott_ids: ottIds,
}),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
success: (innerData) => {
// If successful, we get back the induced tree as a Newick string.
// We use that to create a new phylogeny labeled "Open Tree of Life".
createOTTPhylogenyWithCitation({
label: 'Open Tree of Life',
description: `This phylogeny was generated from the Open Tree of Life based on the following studies: ${innerData.supporting_studies}`,
newick: innerData.newick,
});
},
}).fail((err) => {
// If some OTT ids were not found on the synthetic tree, the OTT API
// will return a list of nodes that could not be matched. We can remove
// these OTT ids from our list of queries and try again.
const regexErrorMessage = /^\[\/v3\/tree_of_life\/induced_subtree\] Error: node_id '\w+' was not found!/;
if (regexErrorMessage.test(err.responseJSON.message)) {
// Step 3. If the response includes node-not-found errors, we can re-run the query without
// the not-found nodes. The response includes an object that lists failed OTT IDs and the
// reason they failed. We report this to the user on the console.
const unknownOttIdReasons = err.responseJSON.unknown;
console.log('The Open Tree synthetic tree does not contain the following nodes: ', unknownOttIdReasons);

// Remove the unknown OTT ids from the list of OTT ids to be queried.
const knownOttIds = ottIds.filter(id => !has(unknownOttIdReasons, "ott" + id));
console.log('Query has been reduced to the following nodes: ', knownOttIds);

if (knownOttIds.length === 0) {
// It may turn out that ALL the OTT IDs are filtered out, in which case we should produce
// a phylogeny for the user with a description that explains what happened.
createOTTPhylogenyWithCitation({
label: 'Open Tree of Life',
description: 'Attempt to load Open Tree of Life tree failed, as none of the Open Tree taxonomy IDs' +
` were present on the synthetic tree: ${JSON.stringify(unknownOttIdReasons, undefined, 4)}`,
newick: '()',
});
} else {
// We have a filtered list of OTT IDs to query. Re-POST the request.
jQuery.ajax({
type: 'POST',
url: OPEN_TREE_INDUCED_SUBTREE_URL,
data: JSON.stringify({
ott_ids: knownOttIds,
}),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
error: innerErr => console.log('Error accessing Open Tree induced_subtree: ', innerErr),
success: (innerData) => {
// If we get an induced phylogeny as a Newick string, create a phylogeny with that Newick string.
createOTTPhylogenyWithCitation({
label: 'Open Tree of Life',
description: `This phylogeny was generated from the Open Tree of Life based on the following studies: ${innerData.supporting_studies}`,
newick: innerData.newick,
});
},
});
}
} else {
// If we got a different error, record it to the Console for future debugging.
console.log('Error accessing Open Tree induced_subtree: ', err);
}
});
},
});
},
},
};