diff --git a/.gitignore b/.gitignore
index c4b9d2495..4a7e9f951 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,5 +20,8 @@ GITHUB_CLIENT_SECRET
GITHUB_APP_PRIVATE_KEY_PEM
smasher/lib/json-simple-1.1.1.jar
deploy/setup/CONFIG
+env
+web2py
+restart.sh
webapp/static/statistics/
lint/
diff --git a/LICENSE.txt b/LICENSE.txt
index 7c5650a3f..51e069afd 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,7 +1,7 @@
-Copyright (c) 2013, Jonathan Rees
-Copyright (c) 2013, Mark Holder
-Copyright (c) 2013, Jim Allman
-Copyright (c) 2013, Stephen Smith
+Copyright (c) 2013-2016, Jonathan Rees
+Copyright (c) 2013-2016, Mark Holder
+Copyright (c) 2013-2016, Jim Allman
+Copyright (c) 2013-2016, Stephen Smith
All rights reserved.
diff --git a/README.md b/README.md
index fc63c97f0..784f1d965 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,89 @@
-opentree
-========
+# opentree
-This is the repository for the Open Tree of Life web applications, one of many subsystems making up the Open Tree of Life project code.
+This is the repository for the Open Tree of Life web applications, one of many subsystems making up
+the Open Tree of Life project code.
+For Open Tree of Life documentation, see
+[the germinator repository's wiki](https://github.com/OpenTreeOfLife/germinator/wiki).
+The 'deployment system' and web API documentation sources that formerly resided in this
+repository now live in the [germinator repository](https://github.com/OpenTreeOfLife/germinator).
+The following instructions have not been reviewed in a long time.
+For local installation a better place to start might be
+[this wiki page](https://github.com/OpenTreeOfLife/opentree/wiki/Installing-a-local-curator-and-tree-browser-test-server).
-For Open Tree of Life documentation, see [the germinator repository's wiki](https://github.com/OpenTreeOfLife/germinator/wiki). The 'deployment system' and web API documentation sources that formerly resided in this repository now live in the [germinator repository](https://github.com/OpenTreeOfLife/germinator).
-
-The following instructions have not been reviewed in a long time. For local installation a better place to start might be [this wiki page](https://github.com/OpenTreeOfLife/opentree/wiki/Installing-a-local-curator-and-tree-browser-test-server).
-
-Installation
-============
-See the phylografter instructions for
-more details about using web2py.
+## Installation
We strongly recommend using a virtual environment to manage the version of
-Python and installed modules. We're currently running opentree with Python
-v2.7.3. Newer versions of python2.7 should work, but **NOTE that web2py is not
+Python and installed modules.
+We're currently running opentree with Python
+v2.7.3.
+Newer versions of python2.7 should work, but **NOTE that web2py is not
compatible with Python 3**.
+The final invocation to create your virtualenv should look something like:
-If necessary, compile Python2.7 and use it when making your virtualenv. You
-should be able to safely install multiple versions of python using your
-preferred package manager, or by configuring Python2.7 with the --prefix
-option and 'make altinstall'.
-
-So the final invocation to create your virtualenv should look something like:
-```
-$ virtualenv --python=/usr/bin/python2.7 --distribute
-pip install -r requirements.txt -+ pip install -r requirements.txt + +### install web2py and link to applications The contents of the webapp subdirectory are a web2py application. Make a symbolic link called "opentree" in a web2py/applications directory to the webapp directory. You should be able to launch web2py and see the app running at http://127.0.0.1:8000/opentree/ -There is now a second web2py app for the curation tool, which will also need a -symlink. This will be available at http://127.0.0.1:8000/curator/ + wget --no-verbose -O web2py_2.8.2_src.zip \ + https://github.com/web2py/web2py/archive/R-2.8.2.zip + unzip web2py_2.8.2_src.zip + mv web2py_2.8.2_src web2py + cd web2py/applications + ln -s ../../webapp opentree + cd - + cp -p oauth20_account.py web2py/gluon/contrib/login_methods/ + cp -p rewrite.py web2py/gluon/ + cp -p custom_import.py web2py/gluon/ + cp -p SITE.routes.py web2py/routes.py -Briefly: -1. Download and unpack the source code version of web2py from -http://www.web2py.com/examples/default/download MTH used version 2.4.2 of web2py +Optionally, you can install a second web2py app for the curation tool, which will also need a +symlink. This will be available at http://127.0.0.1:8000/curator/ - NOTE: This version of web2py includes basic support for OAuth 2.0, but it needs - a minor patch to support for login via the GitHub API v3. (The curation app - uses GitHub for its datastore and attribution. The tree browser also uses it - for its issue tracker, with optional authentication for convenience.) Replace - this web2py file with a modified version in the same folder as this README: -
- {web2py-2.4.4}/gluon/contrib/login_methods/oauth20_account.py -+ cd web2py/applications + ln -s ../../curator curator + cd - -2. Create the sym links for the main web app and the study curation tool. +## Configuring the application +Copy and tweak the template `config` files to provide the variables that will +be available during execution of the web applications: -
- cd web2py/applications - ln -s /full/path/to/opentree/webapp opentree - ln -s /full/path/to/opentree/curator curator -+ for app in webapp curator ; do + cp ${app}/private/config.example ${app}/private/config + done -3. Customize web2py's site-wide routing behavior using "SITE.routes.py" +Edit each `${app}/private/config` file to use the API servers and properties that you + want to use for debugging purposes. -
- # return to main web2py directory - cd .. - cp /full/path/to/opentree/SITE.routes.py routes.py -- - This routing file works in tandem with the opentree app router and lets us have - proper URLs with hyphens instead of underscores. -4. Launch web2py +## Launch web2py for debugging -
- cd /full/path/to/web2py - python web2py.py --nogui -a '<recycle>' -+ cd web2py + python web2py.py --nogui -a '
- cd /full/path/to/web2py + cd web2py sudo python web2py.py --nogui -p 80 -a '<recycle>'diff --git a/curator/controllers/collection.py b/curator/controllers/collection.py index ed13dc8cc..f62ccc22e 100644 --- a/curator/controllers/collection.py +++ b/curator/controllers/collection.py @@ -14,7 +14,7 @@ ######################################################################### from applications.opentree.modules.opentreewebapputil import( - get_opentree_services_method_urls, + get_opentree_api_endpoints, fetch_current_TNRS_context_names, fetch_trees_queued_for_synthesis, get_maintenance_info) @@ -27,7 +27,7 @@ def index(): Show list searchable/filtered list of all collections (default filter = My Collections, if logged in?) """ - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) if auth.is_logged_in(): # user is logged in, filter to their own collections by default? @@ -46,7 +46,7 @@ def view(): ? OR can this include work-in-progress from a personal branch? """ response.view = 'collection/edit.html' - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) #view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) view_dict['collectionID'] = request.args[0] +'/'+ request.args[1] @@ -65,7 +65,7 @@ def create(): if maintenance_info.get('maintenance_in_progress', False): redirect(URL('curator', 'default', 'index', vars={"maintenance_notice":"true"})) pass - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['message'] = "collection/create" return view_dict """ @@ -80,7 +80,7 @@ def edit(): args=request.args)) # Fetch a fresh list of search contexts for TNRS? see working example in # the header search of the main opentree webapp - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) view_dict['treesQueuedForSynthesis'] = fetch_trees_queued_for_synthesis(request) view_dict['collectionID'] = request.args[0] +'/'+ request.args[1] @@ -108,6 +108,17 @@ def load(): def store(): return dict(message="collection/store") +def synthesis_dashboard(): + """ + Allow any visitor to view (read-only!) a queue of recent custom-synthesis runs + """ + response.view = 'collection/synthesis_dashboard.html' + view_dict = get_opentree_services_method_urls(request) + view_dict['maintenance_info'] = get_maintenance_info(request) + view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) + view_dict['userCanEdit'] = auth.is_logged_in() and True or False + return view_dict + """ TODO: Adapt this for current collection status, based on new APIs """ def _get_latest_synthesis_details_for_collection_id( collection_id ): # Fetch the last SHA for this collection that was used in the latest @@ -118,7 +129,7 @@ def _get_latest_synthesis_details_for_collection_id( collection_id ): import json import requests - method_dict = get_opentree_services_method_urls(request) + method_dict = get_opentree_api_endpoints(request) # fetch a list of all studies and collections that contribute to synthesis # TODO: Request that these fields be added @@ -137,15 +148,6 @@ def _get_latest_synthesis_details_for_collection_id( collection_id ): # Draft code is based on schema proposed in # https://github.com/OpenTreeOfLife/phylesystem-api/issues/228 - # fetch the full source list, then look for this study and its trees - commit_SHA_in_synthesis = None - # if key (collection ID, e.g. "opentreeoflife/default") matches, read its details - for c_id, collection_details in source_dict.items(): - if c_id == collection_id: - # this is the collection we're interested in! - commit_SHA_in_synthesis = collection_details['git_sha'] - return commit_SHA_in_synthesis # TODO: return more information? - # fetch the full source list, then look for this collection and its SHA # if key (collection ID, e.g. "opentreeoflife/default") matches, read its details for c_id, collection_details in source_dict.items(): @@ -155,4 +157,5 @@ def _get_latest_synthesis_details_for_collection_id( collection_id ): return None except Exception, e: # throw 403 or 500 or just leave it - raise HTTP(500, T('Unable to retrieve latest synthesis details for collection {u}'.format(u=collection))) + ##raise HTTP(500, T('Unable to retrieve latest synthesis details for collection {u}'.format(u=collection_id))) + raise HTTP(500, T('Unable to retrieve latest synthesis details for collection {u}:\n\n{e}'.format(u=collection_id, e=e))) diff --git a/curator/controllers/default.py b/curator/controllers/default.py index df7d7b3d8..6907362b6 100644 --- a/curator/controllers/default.py +++ b/curator/controllers/default.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from applications.opentree.modules.opentreewebapputil import( - get_opentree_services_method_urls, + get_opentree_api_endpoints, extract_nexson_from_http_call, fetch_github_app_auth_token, get_maintenance_info) @@ -29,7 +29,7 @@ def index(): a logged-in user. """ #response.flash = T("Welcome to web2py!") - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) if False: ## auth.is_logged_in(): @@ -45,12 +45,12 @@ def collections(): TODO: move to collection/index? """ - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) return view_dict def error(): - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) return view_dict @auth.requires_login() @@ -78,7 +78,7 @@ def profile(): shows a personalized profile for any user (default = the current logged-in user) http://..../{app}/default/profile/[username] """ - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) # if the URL has a [username], try to load their information @@ -192,7 +192,7 @@ def _get_opentree_activity( userid=None, username=None ): 'added_collections':[], 'curated_collections':[] } - method_dict = get_opentree_services_method_urls(request) + method_dict = get_opentree_api_endpoints(request) # Use GitHub API to gather comments from this user, as shown in # https://github.com/OpenTreeOfLife/feedback/issues/created_by/jimallman @@ -470,6 +470,7 @@ def to_nexson(): _LOG = get_logger(request, 'to_nexson') if request.env.request_method == 'OPTIONS': raise HTTP(200, T('Preflight approved!')) + orig_args = {} is_upload = False # several of our NexSON use "uploadid" instead of "uploadId" so we should accept either @@ -622,7 +623,7 @@ def to_nexson(): try: assert(os.path.exists(exe_path)) except: - response.view = 'generic.json'; return {'hb':exe_path} + #response.view = 'generic.json'; return {'hb':exe_path} _LOG.warn("Could not find the 2nexml executable") raise HTTP(501, T("Server is misconfigured for 2nexml conversion")) invoc = [exe_path, '-f{f}'.format(f=inp_format), ] diff --git a/curator/controllers/study.py b/curator/controllers/study.py index 1e1a9627c..47ce44069 100644 --- a/curator/controllers/study.py +++ b/curator/controllers/study.py @@ -12,7 +12,7 @@ ######################################################################### from applications.opentree.modules.opentreewebapputil import( - get_opentree_services_method_urls, + get_opentree_api_endpoints, fetch_current_TNRS_context_names, fetch_trees_queued_for_synthesis, get_maintenance_info) @@ -25,7 +25,7 @@ def index(): Show list searchable/filtered list of all studies (default filter = My Studies, if logged in?) """ - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) if auth.is_logged_in(): # user is logged in, filter to their own studies by default? @@ -44,7 +44,7 @@ def view(): ? OR can this include work-in-progress from a personal branch? """ response.view = 'study/edit.html' - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['maintenance_info'] = get_maintenance_info(request) #view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) view_dict['studyID'] = request.args[0] @@ -61,7 +61,7 @@ def create(): if maintenance_info.get('maintenance_in_progress', False): redirect(URL('curator', 'default', 'index', vars={"maintenance_notice":"true"})) pass - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['message'] = "study/create" return view_dict @@ -76,7 +76,7 @@ def edit(): args=request.args)) # Fetch a fresh list of search contexts for TNRS? see working example in # the header search of the main opentree webapp - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) view_dict['treesQueuedForSynthesis'] = fetch_trees_queued_for_synthesis(request) view_dict['studyID'] = request.args[0] @@ -93,7 +93,7 @@ def _get_latest_synthesis_details_for_study_id( study_id ): import json import requests - method_dict = get_opentree_services_method_urls(request) + method_dict = get_opentree_api_endpoints(request) # fetch a list of all studies that contribute to synthesis fetch_url = method_dict['getSynthesisSourceList_url'] diff --git a/curator/controllers/tnrs.py b/curator/controllers/tnrs.py index 450edadd1..96888a812 100644 --- a/curator/controllers/tnrs.py +++ b/curator/controllers/tnrs.py @@ -6,7 +6,7 @@ ######################################################################### from applications.opentree.modules.opentreewebapputil import( - get_opentree_services_method_urls, + get_opentree_api_endpoints, fetch_current_TNRS_context_names, get_maintenance_info) @@ -19,7 +19,7 @@ def index(): """ response.view = 'tnrs.html' - view_dict = get_opentree_services_method_urls(request) + view_dict = get_opentree_api_endpoints(request) #view_dict['message'] = "This would appear at bottom of page.." view_dict['maintenance_info'] = get_maintenance_info(request) view_dict['taxonSearchContextNames'] = fetch_current_TNRS_context_names(request) diff --git a/curator/private/config.example b/curator/private/config.example index 96f3f0982..fc58b34d5 100644 --- a/curator/private/config.example +++ b/curator/private/config.example @@ -34,6 +34,9 @@ github_redirect_uri = YOUR_REDIRECT_URI_HERE # (the installation ID is in the URL of the Configure button here) github_app_installation_id = YOUR_APP_INSTALLATION_ID_HERE +# +# TODO: Revise both API sections below to match our Ansible stuff, or delete this file entirely! +# # List public-facing base URL for supporting data services # (NOTE that these are used by both server- and client-side code) [domains] @@ -60,6 +63,9 @@ getContextForNames_url = {taxomachine_domain}/v3/tnrs/infer_context getSynthesisSourceList_url = {CACHED_treemachine_domain}/v3/tree_of_life/about getTaxonomicMRCAForNodes_url = {taxomachine_domain}/v3/taxonomy/mrca getDraftTreeMRCAForNodes_url = {treemachine_domain}/v3/tree_of_life/mrca +findAllSynthesisRuns_url = https://ot38.opentreeoflife.org/v3/tree_of_life/list_custom_built_trees +requestNewSynthesisRun_url = https://ot38.opentreeoflife.org/v3/tree_of_life/build_tree +# TODO: This should start {treemachine_domain} OR {CACHED_treemachine_domain} findAllStudies_url = {CACHED_oti_domain}/v3/studies/find_studies # TODO: Can we use CACHED_oti_domain for this? singlePropertySearchForStudies_url = {oti_domain}/v3/studies/find_studies diff --git a/curator/routes.py b/curator/routes.py new file mode 100644 index 000000000..8bfaeb14a --- /dev/null +++ b/curator/routes.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +# adapted from router.example.py + +# NOTE that this requires a parametric router in the web2py root directory. +# Let's keep all the important stuff here, and just copy a minimal router +# (SITE.routes.py) into the site root. + +# NOTE that this (app-specific) routes.py file mainly defines a router by the +# same name. More general settings must be done in the main routes.py alongside +# the web2py/applications/ directory +# root_static (for favicon.ico, robots.txt, etc) +# routes_onerror (defines error pages per app, per error code, or defaults) +# domain (maps domain names and ports to particular app) +# See SITE.routes.py for recommended settings. + +routers = dict( + curator=dict( + # convert dashes (hyphens) in URLs to underscores in web2py controller+action names + map_hyphen=True, + ), +) + +# see router.example.py for (many) more options! diff --git a/curator/static/css/default.css b/curator/static/css/default.css index f182447dd..c975d5871 100644 --- a/curator/static/css/default.css +++ b/curator/static/css/default.css @@ -870,7 +870,13 @@ tr.after-shims th { position: relative; top: -8px; } -.collection-move-panel { + +.form-horizontal.metadata-readonly .control-group { + margin-bottom: 12px; +} + +.collection-move-panel, +.synthrun-move-panel { position: absolute; margin-left: 2px; z-index: 1; @@ -880,7 +886,8 @@ tr.after-shims th { -moz-box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); } -.collection-move-panel button { +.collection-move-panel button, +.synthrun-move-panel button { opacity: 1.0; } @@ -1014,3 +1021,11 @@ form.skip-label * { form.skip-label *.non-fading { opacity: 1.0; } +.loading-message { + display: inline-block; + position: relative; + top: -6px; + left: 8px; + color: #999; + font-style: italic; +} diff --git a/curator/static/js/bootstrap-tagsinput.js b/curator/static/js/bootstrap-tagsinput.js new file mode 100644 index 000000000..4c97aceea --- /dev/null +++ b/curator/static/js/bootstrap-tagsinput.js @@ -0,0 +1,503 @@ +(function ($) { + "use strict"; + + var defaultOptions = { + tagClass: function(item) { + return 'label label-info'; + }, + itemValue: function(item) { + return item ? item.toString() : item; + }, + itemText: function(item) { + return this.itemValue(item); + }, + freeInput: true, + maxTags: undefined, + confirmKeys: [13], + onTagExists: function(item, $tag) { + $tag.hide().fadeIn(); + } + }; + + /** + * Constructor function + */ + function TagsInput(element, options) { + this.itemsArray = []; + + this.$element = $(element); + this.$element.hide(); + + this.isSelect = (element.tagName === 'SELECT'); + this.multiple = (this.isSelect && element.hasAttribute('multiple')); + this.objectItems = options && options.itemValue; + this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; + this.inputSize = Math.max(1, this.placeholderText.length); + + this.$container = $(''); + this.$input = $('').appendTo(this.$container); + + this.$element.after(this.$container); + + this.build(options); + } + + TagsInput.prototype = { + constructor: TagsInput, + + /** + * Adds the given item as a new tag. Pass true to dontPushVal to prevent + * updating the elements val() + */ + add: function(item, dontPushVal) { + var self = this; + + if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) + return; + + // Ignore falsey values, except false + if (item !== false && !item) + return; + + // Throw an error when trying to add an object while the itemValue option was not set + if (typeof item === "object" && !self.objectItems) + throw("Can't add objects when itemValue option is not set"); + + // Ignore strings only containg whitespace + if (item.toString().match(/^\s*$/)) + return; + + // If SELECT but not multiple, remove current tag + if (self.isSelect && !self.multiple && self.itemsArray.length > 0) + self.remove(self.itemsArray[0]); + + if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { + var items = item.split(','); + if (items.length > 1) { + for (var i = 0; i < items.length; i++) { + this.add(items[i], true); + } + + if (!dontPushVal) + self.pushVal(); + return; + } + } + + var itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item); + + // Ignore items allready added + var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; + if (existing) { + // Invoke onTagExists + if (self.options.onTagExists) { + var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); + self.options.onTagExists(item, $existingTag); + } + return; + } + + // register item in internal array and map + self.itemsArray.push(item); + + // add a tag element + var $tag = $('' + htmlEncode(itemText) + ''); + $tag.data('item', item); + self.findInputWrapper().before($tag); + $tag.after(' '); + + // add if item represents a value not present in one of the 's options + if (self.isSelect && !$('option[value="' + escape(itemValue) + '"]',self.$element)[0]) { + var $option = $(''); + $option.data('item', item); + $option.attr('value', itemValue); + self.$element.append($option); + } + + if (!dontPushVal) + self.pushVal(); + + // Add class when reached maxTags + if (self.options.maxTags === self.itemsArray.length) + self.$container.addClass('bootstrap-tagsinput-max'); + + self.$element.trigger($.Event('itemAdded', { item: item })); + }, + + /** + * Removes the given item. Pass true to dontPushVal to prevent updating the + * elements val() + */ + remove: function(item, dontPushVal) { + var self = this; + + if (self.objectItems) { + if (typeof item === "object") + item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == self.options.itemValue(item); } )[0]; + else + item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == item; } )[0]; + } + + if (item) { + $('.tag', self.$container).filter(function() { return $(this).data('item') === item; }).remove(); + $('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove(); + self.itemsArray.splice($.inArray(item, self.itemsArray), 1); + } + + if (!dontPushVal) + self.pushVal(); + + // Remove class when reached maxTags + if (self.options.maxTags > self.itemsArray.length) + self.$container.removeClass('bootstrap-tagsinput-max'); + + self.$element.trigger($.Event('itemRemoved', { item: item })); + }, + + /** + * Removes all items + */ + removeAll: function() { + var self = this; + + $('.tag', self.$container).remove(); + $('option', self.$element).remove(); + + while(self.itemsArray.length > 0) + self.itemsArray.pop(); + + self.pushVal(); + + if (self.options.maxTags && !this.isEnabled()) + this.enable(); + }, + + /** + * Refreshes the tags so they match the text/value of their corresponding + * item. + */ + refresh: function() { + var self = this; + $('.tag', self.$container).each(function() { + var $tag = $(this), + item = $tag.data('item'), + itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item); + + // Update tag's class and inner text + $tag.attr('class', null); + $tag.addClass('tag ' + htmlEncode(tagClass)); + $tag.contents().filter(function() { + return this.nodeType == 3; + })[0].nodeValue = htmlEncode(itemText); + + if (self.isSelect) { + var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; }); + option.attr('value', itemValue); + } + }); + }, + + /** + * Returns the items added as tags + */ + items: function() { + return this.itemsArray; + }, + + /** + * Assembly value by retrieving the value of each item, and set it on the + * element. + */ + pushVal: function() { + var self = this, + val = $.map(self.items(), function(item) { + return self.options.itemValue(item).toString(); + }); + + self.$element.val(val, true).trigger('change'); + }, + + /** + * Initializes the tags input behaviour on the element + */ + build: function(options) { + var self = this; + + self.options = $.extend({}, defaultOptions, options); + var typeahead = self.options.typeahead || {}; + + // When itemValue is set, freeInput should always be false + if (self.objectItems) + self.options.freeInput = false; + + makeOptionItemFunction(self.options, 'itemValue'); + makeOptionItemFunction(self.options, 'itemText'); + makeOptionItemFunction(self.options, 'tagClass'); + + // for backwards compatibility, self.options.source is deprecated + if (self.options.source) + typeahead.source = self.options.source; + + if (typeahead.source && $.fn.typeahead) { + makeOptionFunction(typeahead, 'source'); + + self.$input.typeahead({ + source: function (query, process) { + function processItems(items) { + var texts = []; + + for (var i = 0; i < items.length; i++) { + var text = self.options.itemText(items[i]); + map[text] = items[i]; + texts.push(text); + } + process(texts); + } + + this.map = {}; + var map = this.map, + data = typeahead.source(query); + + if ($.isFunction(data.success)) { + // support for Angular promises + data.success(processItems); + } else { + // support for functions and jquery promises + $.when(data) + .then(processItems); + } + }, + updater: function (text) { + self.add(this.map[text]); + }, + matcher: function (text) { + return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1); + }, + sorter: function (texts) { + return texts.sort(); + }, + highlighter: function (text) { + var regex = new RegExp( '(' + this.query + ')', 'gi' ); + return text.replace( regex, "$1" ); + } + }); + } + + self.$container.on('click', $.proxy(function(event) { + self.$input.focus(); + }, self)); + + self.$container.on('keydown', 'input', $.proxy(function(event) { + var $input = $(event.target), + $inputWrapper = self.findInputWrapper(); + + switch (event.which) { + // BACKSPACE + case 8: + if (doGetCaretPosition($input[0]) === 0) { + var prev = $inputWrapper.prev(); + if (prev) { + self.remove(prev.data('item')); + } + } + break; + + // DELETE + case 46: + if (doGetCaretPosition($input[0]) === 0) { + var next = $inputWrapper.next(); + if (next) { + self.remove(next.data('item')); + } + } + break; + + // LEFT ARROW + case 37: + // Try to move the input before the previous tag + var $prevTag = $inputWrapper.prev(); + if ($input.val().length === 0 && $prevTag[0]) { + $prevTag.before($inputWrapper); + $input.focus(); + } + break; + // RIGHT ARROW + case 39: + // Try to move the input after the next tag + var $nextTag = $inputWrapper.next(); + if ($input.val().length === 0 && $nextTag[0]) { + $nextTag.after($inputWrapper); + $input.focus(); + } + break; + default: + // When key corresponds one of the confirmKeys, add current input + // as a new tag + if (self.options.freeInput && $.inArray(event.which, self.options.confirmKeys) >= 0) { + self.add($input.val()); + $input.val(''); + event.preventDefault(); + } + } + + // Reset internal input's size + $input.attr('size', Math.max(this.inputSize, $input.val().length)); + }, self)); + + // Remove icon clicked + self.$container.on('click', '[data-role=remove]', $.proxy(function(event) { + self.remove($(event.target).closest('.tag').data('item')); + }, self)); + + // Only add existing value as tags when using strings as tags + if (self.options.itemValue === defaultOptions.itemValue) { + if (self.$element[0].tagName === 'INPUT') { + self.add(self.$element.val()); + } else { + $('option', self.$element).each(function() { + self.add($(this).attr('value'), true); + }); + } + } + }, + + /** + * Removes all tagsinput behaviour and unregsiter all event handlers + */ + destroy: function() { + var self = this; + + // Unbind events + self.$container.off('keypress', 'input'); + self.$container.off('click', '[role=remove]'); + + self.$container.remove(); + self.$element.removeData('tagsinput'); + self.$element.show(); + }, + + /** + * Sets focus on the tagsinput + */ + focus: function() { + this.$input.focus(); + }, + + /** + * Returns the internal input element + */ + input: function() { + return this.$input; + }, + + /** + * Returns the element which is wrapped around the internal input. This + * is normally the $container, but typeahead.js moves the $input element. + */ + findInputWrapper: function() { + var elt = this.$input[0], + container = this.$container[0]; + while(elt && elt.parentNode !== container) + elt = elt.parentNode; + + return $(elt); + } + }; + + /** + * Register JQuery plugin + */ + $.fn.tagsinput = function(arg1, arg2) { + var results = []; + + this.each(function() { + var tagsinput = $(this).data('tagsinput'); + + // Initialize a new tags input + if (!tagsinput) { + tagsinput = new TagsInput(this, arg1); + $(this).data('tagsinput', tagsinput); + results.push(tagsinput); + + if (this.tagName === 'SELECT') { + $('option', $(this)).attr('selected', 'selected'); + } + + // Init tags from $(this).val() + $(this).val($(this).val()); + } else { + // Invoke function on existing tags input + var retVal = tagsinput[arg1](arg2); + if (retVal !== undefined) + results.push(retVal); + } + }); + + if ( typeof arg1 == 'string') { + // Return the results from the invoked function calls + return results.length > 1 ? results : results[0]; + } else { + return results; + } + }; + + $.fn.tagsinput.Constructor = TagsInput; + + /** + * Most options support both a string or number as well as a function as + * option value. This function makes sure that the option with the given + * key in the given options is wrapped in a function + */ + function makeOptionItemFunction(options, key) { + if (typeof options[key] !== 'function') { + var propertyName = options[key]; + options[key] = function(item) { return item[propertyName]; }; + } + } + function makeOptionFunction(options, key) { + if (typeof options[key] !== 'function') { + var value = options[key]; + options[key] = function() { return value; }; + } + } + /** + * HtmlEncodes the given value + */ + var htmlEncodeContainer = $(''); + function htmlEncode(value) { + if (value) { + return htmlEncodeContainer.text(value).html(); + } else { + return ''; + } + } + + /** + * Returns the position of the caret in the given input field + * http://flightschool.acylt.com/devnotes/caret-position-woes/ + */ + function doGetCaretPosition(oField) { + var iCaretPos = 0; + if (document.selection) { + oField.focus (); + var oSel = document.selection.createRange(); + oSel.moveStart ('character', -oField.value.length); + iCaretPos = oSel.text.length; + } else if (oField.selectionStart || oField.selectionStart == '0') { + iCaretPos = oField.selectionStart; + } + return (iCaretPos); + } + + /** + * Initialize tagsinput behaviour on inputs and selects which have + * data-role=tagsinput + */ + $(function() { + $("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput(); + }); +})(window.jQuery); diff --git a/curator/static/js/curation-helpers.js b/curator/static/js/curation-helpers.js index 35ba8e410..8fec890d2 100644 --- a/curator/static/js/curation-helpers.js +++ b/curator/static/js/curation-helpers.js @@ -539,6 +539,7 @@ function slugify(str) { var userLogin; var userDisplayName; var singlePropertySearchForTrees_url; +var requestNewSynthesisRun_url; function fetchAndShowCollection( collectionID, specialHandling ) { /* Fetch a known-good collection from the tree-collections API, and open it @@ -657,7 +658,7 @@ async function showCollectionViewer( collection, options ) { $newTreeOptionsPanels.find('input').val(''); $newTreeByURLButton.attr('disabled', 'disabled') .addClass('btn-info-disabled'); - updateNewCollTreeUI(); + updateTreeLookupUI(); // (re)bind study and tree lookups loadStudyListForLookup(); // disable the Add Tree button until they finish or cancel @@ -778,57 +779,76 @@ function getFullGitHubURLForCollection(collection) { return ''; } -function updateNewCollTreeUI() { +function updateTreeLookupUI() { + // find the correct UI components for the current context + var context = getPhylesystemLookupContext(); + // what's the parent element for study+tree lookup UI? + var $container = getPhylesystemLookupPanel( context ); // update by-lookup widgets var $addByLookupPanel = $('#new-collection-tree-by-lookup'); var $submitByLookupButton = $addByLookupPanel.find('button').eq(0); var $studyIDField = $addByLookupPanel.find('input[name=study-lookup-id]'); var $treeSelector = $addByLookupPanel.find('select[name=tree-lookup]'); - var $submitByAnyInputButton = $('#add-tree-by-any-input'); - if (collectionUI === 'FULL_PAGE') { - // disable our all-purpose add-tree button, then check below - $submitByAnyInputButton.attr('disabled', 'disabled') - .addClass('btn-info-disabled'); - } + switch(context) { + case 'COLLECTION_EDITOR_ADD_TREE': + var $submitByAnyInputButton = $('#add-tree-by-any-input'); + if (collectionUI === 'FULL_PAGE') { + // disable our all-purpose add-tree button, then check below + $submitByAnyInputButton.attr('disabled', 'disabled') + .addClass('btn-info-disabled'); + } - if (($.trim($studyIDField.val()) == '') || ($.trim($treeSelector.val()) == '')) { - // no ids found! - if (collectionUI === 'POPUP') { - $submitByLookupButton.attr('disabled', 'disabled') - .addClass('btn-info-disabled'); - } - } else { - // both ids found! - if (collectionUI === 'POPUP') { - $submitByLookupButton.attr('disabled', null) - .removeClass('btn-info-disabled'); - } else { - $submitByAnyInputButton.attr('disabled', null) - .removeClass('btn-info-disabled'); - } - } + if (($.trim($studyIDField.val()) == '') || ($.trim($treeSelector.val()) == '')) { + // no ids found! + if (collectionUI === 'POPUP') { + $submitByLookupButton.attr('disabled', 'disabled') + .addClass('btn-info-disabled'); + } + } else { + // both ids found! + if (collectionUI === 'POPUP') { + $submitByLookupButton.attr('disabled', null) + .removeClass('btn-info-disabled'); + } else { + $submitByAnyInputButton.attr('disabled', null) + .removeClass('btn-info-disabled'); + } + } - // update by-URL widgets - var $addByURLPanel = $('#new-collection-tree-by-url'); - var $urlField = $addByURLPanel.find('input[name=tree-url]'); - var $submitByURLButton = $addByURLPanel.find('button').eq(0); - if ($.trim($urlField.val()) == '') { - if (collectionUI === 'POPUP') { - $submitByURLButton.attr('disabled', 'disabled') - .addClass('btn-info-disabled'); - } - } else { - if (collectionUI === 'POPUP') { - $submitByURLButton.attr('disabled', null) - .removeClass('btn-info-disabled'); - } else { - $submitByAnyInputButton.attr('disabled', null) - .removeClass('btn-info-disabled'); - } + // update by-URL widgets + var $addByURLPanel = $('#new-collection-tree-by-url'); + var $urlField = $addByURLPanel.find('input[name=tree-url]'); + var $submitByURLButton = $addByURLPanel.find('button').eq(0); + if ($.trim($urlField.val()) == '') { + if (collectionUI === 'POPUP') { + $submitByURLButton.attr('disabled', 'disabled') + .addClass('btn-info-disabled'); + } + } else { + if (collectionUI === 'POPUP') { + $submitByURLButton.attr('disabled', null) + .removeClass('btn-info-disabled'); + } else { + $submitByAnyInputButton.attr('disabled', null) + .removeClass('btn-info-disabled'); + } + } + break; + case 'PHYLOGRAM_CONFLICT_CHOOSE_TREE2': + break; + case 'ANALYSES_CONFLICT_CHOOSE_TREE2': + break; + default: // missing/unknown context! + return; } } -/* Sensible autocomplete behavior requires the use of timeouts +/* Look up study and tree IDs easily. + * + * This logic was originally used only in the collection editor, but + * we need to generalize it for use in other contexts like conflict reporting. + * + * NB - Sensible autocomplete behavior requires the use of timeouts * and sanity checks for unchanged content, etc. */ clearTimeout(studyLookupTimeoutID); // in case there's a lingering search from last page! @@ -843,6 +863,12 @@ function setStudyLookupFuse(e) { // reset the timeout for another n milliseconds studyLookupTimeoutID = setTimeout(searchForMatchingStudy, lookupDelay); + // find the correct UI components for the current context + var context = getPhylesystemLookupContext(); + // what's the parent element for study+tree lookup UI? + var $container = getPhylesystemLookupPanel( context ); + var $lookupResults = $container.find('[id=study-lookup-results]'); // display matching stuff + /* If the last key pressed was the ENTER key, stash the current (trimmed) * string and auto-jump if it's a valid taxon name. */ @@ -858,7 +884,7 @@ function setStudyLookupFuse(e) { case 39: case 40: // down or right arrows should try to select first result - $('#study-lookup-results a:eq(0)').focus(); + $lookupResults.find('a:eq(0)').focus(); break; default: hopefulStudyLookupName = null; @@ -868,33 +894,98 @@ function setStudyLookupFuse(e) { } } +function getPhylesystemLookupContext() { + /* Check for open (and topmost) popup, then active tab + * + * NB - It's entirely possible to open the source-tree viewer, then + * the collection editor on top of that. Choose wisely! + */ + if ($('#tree-collection-viewer').is(":visible") || + $('div#Home [id=tree-collection-viewer]').length === 1) { + // we're editing a collection, either in a popup modal OR the full-page editor + return 'COLLECTION_EDITOR_ADD_TREE'; + } + + var $tabBar = $('ul.nav-tabs:eq(0)'); + var activeTabName = $.trim($tabBar.find('li.active a').text()); + if (activeTabName.indexOf('Analyses') === 0) { + return 'ANALYSES_CONFLICT_CHOOSE_TREE2'; + } + + if ((activeTabName.indexOf('Home') === 0) || + $('#tree-viewer').is(":visible") || + treeViewerIsInUse) { + return 'PHYLOGRAM_CONFLICT_CHOOSE_TREE2'; + } + + console.error("getPhylesystemLookupContext(): UNKNOWN context!"); + return null; +} + +function getPhylesystemLookupPanel( context ) { + // find the nearest containing element for all phylesystem-lookup widgets + context = context || getPhylesystemLookupContext(); + var $container; // parent element for study+tree lookup UI + switch(context) { + case 'COLLECTION_EDITOR_ADD_TREE': + $container = $('#new-collection-tree-by-lookup'); + break; + case 'PHYLOGRAM_CONFLICT_CHOOSE_TREE2': + $container = $('#tree-phylogram-options'); + break; + case 'ANALYSES_CONFLICT_CHOOSE_TREE2': + $container = $('#analyses-tree-chooser'); + break; + default: // missing/unknown context! + console.error("getPhylesystemLookupPanel(): UNKNOWN context '"+ context +"'!"); + return null; + } + return $container; +} + var showingResultsForStudyLookupText = ''; function searchForMatchingStudy() { // clear any pending lookup timeout and ID clearTimeout(studyLookupTimeoutID); studyLookupTimeoutID = null; - var $input = $('input[name=study-lookup]'); - if ($input.length === 0) { - $('#study-lookup-results').html(''); + // find the correct UI components for the current context + var context = getPhylesystemLookupContext(); + // what's the parent element for study+tree lookup UI? + var $container = getPhylesystemLookupPanel( context ); + switch(context) { + case 'COLLECTION_EDITOR_ADD_TREE': + break; + case 'PHYLOGRAM_CONFLICT_CHOOSE_TREE2': + break; + case 'ANALYSES_CONFLICT_CHOOSE_TREE2': + break; + default: // missing/unknown context! + return; + } + var $studyNameInput = $container.find('input[name=study-lookup]'); // search text field + var $lookupResults = $container.find('[id=study-lookup-results]'); // display matching stuff + + if ($studyNameInput.length === 0) { + $container.find('#study-lookup-results').html(''); console.log("Input field not found!"); return false; } - var searchText = $.trim( $input.val() ); + var searchText = $.trim( $studyNameInput.val() ); var searchTokens = tokenizeSearchTextKeepingQuotes(searchText); if ((searchTokens.length === 0) || (searchTokens.length === 1 && searchTokens[0].length < 2)) { - $('#study-lookup-results').html('
'+ jqXHR.responseText +''; + $('#search-results').find('span.detail-toggle').click(function(e) { + e.preventDefault(); + showErrorMessage(errDetails); + return false; + }); + $('#search-results').dropdown('toggle'); + } + } + return; + } + }); + + return false; +} + +function autoApplyExactMatch() { + // if the user hit the ENTER key, and there's an exact match, apply it automatically + if (hopefulSearchName) { + $('#search-results a').each(function() { + var $link = $(this); + if ($link.text().toLowerCase() === hopefulSearchName.toLowerCase()) { + $link.trigger('click'); + return false; + } + }); + } +} + +async function promptToAddCollection(clicked) { + // show the autocomplete widget and mute this button + var $btn = $(clicked); + $btn.addClass('disabled'); + var $collectionPrompt = $('#add-collection-search-form'); + $collectionPrompt.show() + $collectionPrompt.find('input').eq(0).focus(); +} + +function resetExistingCollectionPrompt( options ) { + options = options || {}; + if (!options.CONTEXT) { + // are we editing synth-run details or adding a tree? + options.CONTEXT = 'ADD_TREE_TO_COLLECTION'; + } + var promptSelector, + resultsSelector; + switch (options.CONTEXT) { + case 'ADD_TREE_TO_COLLECTION': + promptSelector = '#collection-search-form'; + resultsSelector = '#collection-search-results'; + break; + case 'ADD_COLLECTION_TO_SYNTHESIS_RUN': + promptSelector = '#add-collection-search-form'; + resultsSelector = '#add-collection-search-results'; + break; + default: + console.error("resetExistingCollectionPrompt(): ERROR, unknown context: '"+ options.CONTEXT +"'!"); + return; + } + var $collectionPrompt = $(promptSelector); + var $collectionResults = $(resultsSelector); + + var $btn = $collectionPrompt.prev('.btn'); + $btn.removeClass('disabled'); + $collectionPrompt.hide(); + $collectionPrompt.find('input').val(''); + $collectionResults.html(''); + $collectionResults.hide(); +} + +/* More autocomplete behavior for tree-collection search. + */ +clearTimeout(collectionSearchTimeoutID); // in case there's a lingering search from last page! +var collectionSearchTimeoutID = null; +var collectionSearchDelay = 250; // milliseconds +var hopefulCollectionSearchString = null; +function setCollectionSearchFuse(e) { + if (collectionSearchTimeoutID) { + // kill any pending search, apparently we're still typing + clearTimeout(collectionSearchTimeoutID); + } + var CONTEXT; + if ($(e.target).closest('#define-synth-run-popup').length === 1) { + CONTEXT = 'ADD_COLLECTION_TO_SYNTHESIS_RUN'; + } else { + CONTEXT = 'ADD_TREE_TO_COLLECTION'; + } + // reset the timeout for another n milliseconds + collectionSearchTimeoutID = setTimeout(function() { + searchForMatchingCollections( {CONTEXT: CONTEXT} ); + }, collectionSearchDelay); + + /* If the last key pressed was the ENTER key, stash the current (trimmed) + * string and auto-jump if it's a valid taxon name. + */ + if (e.type === 'keyup') { + switch (e.which) { + case 13: + hopefulCollectionSearchString = $('input[name=collection-search]').val().trim(); + // TODO? jumpToExactMatch(); // use existing menu, if found + break; + case 17: + // do nothing (probably a second ENTER key) + break; + case 39: + case 40: + // down or right arrow should try to tab to first result + $('#collection-search-results a:eq(0)').focus(); + break; + default: + hopefulCollectionSearchString = null; + } + } else { + hopefulCollectionSearchString = null; + } +} + +var showingResultsForCollectionSearchText = ''; +function searchForMatchingCollections( options ) { + options = options || {}; + if (!options.CONTEXT) { + // are we editing synth-run details or adding a tree? + options.CONTEXT = 'ADD_TREE_TO_COLLECTION'; + } + var promptSelector, + resultsSelector, + linkViewBehavior; + switch (options.CONTEXT) { + case 'ADD_TREE_TO_COLLECTION': + promptSelector = '#collection-search-form'; + resultsSelector = '#collection-search-results'; + linkViewBehavior = 'POPUP'; + break; + case 'ADD_COLLECTION_TO_SYNTHESIS_RUN': + promptSelector = '#add-collection-search-form'; + resultsSelector = '#add-collection-search-results'; + linkViewBehavior = 'FULL_PAGE'; // suppress popup on click + break; + default: + console.error("resetExistingCollectionPrompt(): ERROR, unknown context: '"+ options.CONTEXT +"'!"); + return; + } + var $collectionPrompt = $(promptSelector); + var $collectionResults = $(resultsSelector); + + // clear any pending search timeout and ID + clearTimeout(collectionSearchTimeoutID); + collectionSearchTimeoutID = null; + + var $input = $('input[name=collection-search]'); // in all contexts! + var searchText = $input.val().trimLeft(); + + if (searchText.length === 0) { + $collectionResults.html(''); + return false; + } else if (searchText.length < 2) { + $collectionResults.html('
+ The OpenTree projects builds + our synthetic tree of life. + by merging a list of tree collections over the "backbone" of the + latest OpenTree Taxonomy. + This page shows a list of all recorded synthesis runs in the + current system, the collections and settings used for each run. +
++ Our synthesis tools are now available for you to use! + TODO: add lots more detail here. +
+Synthesis run id | +Settings | +Status | +Date completed | + +Actions | +
---|---|---|---|---|
+ — + + >[show details] + | +TODO | +— | +— | ++ + Synthesis details + + + | +
+ This run included collections: + LINK ++ |
+