/* global define */
define(["jquery",
"underscore",
"backbone",
"uuid",
"collections/Filters",
"collections/SolrResults",
"models/DataONEObject",
"models/filters/Filter",
"models/filters/FilterGroup",
"models/Search",
],
function($, _, Backbone, uuid, Filters, SolrResults, DataONEObject, Filter, FilterGroup, Search) {
/**
* @class CollectionModel
* @classdesc A collection of datasets, defined by one or more search filters
* @classcategory Models
* @name CollectionModel
* @extends DataONEObject
* @constructor
*/
var CollectionModel = DataONEObject.extend(
/** @lends CollectionModel.prototype */{
/**
* The name of this Model
* @type {string}
* @readonly
*/
type: "Collection",
/**
* Default attributes for CollectionModels
* @type {Object}
* @property {string[]} ignoreQueryGroups - Deprecated
* @property {FilterGroup} definition - The parent-level Filter Group model that represents the collection definition.
* @property {Filters} definitionFilters - A Filters collection that stores definition filters that have been serialized to the Collection. The same filters that are stored in the definition.
* @property {Search} searchModel - A Search model with a Filters collection that contains the filters associated with this collection
* @property {SolrResults} searchResults - A SolrResults collection that contains the filtered search results of datasets in this collection
* @property {SolrResults} allSearchResults - A SolrResults collection that contains the unfiltered search results of all datasets in this collection
*/
defaults: function(){
return _.extend(DataONEObject.prototype.defaults(), {
name: null,
label: null,
originalLabel: null,
labelBlockList: ["new"],
description: null,
formatId: "https://purl.dataone.org/collections-1.1.0",
formatType: "METADATA",
type: "collection",
definition: null,
definitionFilters: null,
searchModel: null,
searchResults: new SolrResults(),
allSearchResults: null
});
},
/**
* The default Backbone.Model.initialize() function
*/
initialize: function(options){
//Call the super class initialize function
DataONEObject.prototype.initialize.call(this, options);
this.listenToOnce(this.get("searchResults"), "sync", this.cacheSearchResults);
//If the searchResults collection is replaced at any time, reset the listener
this.off("change:searchResults")
this.on("change:searchResults", function(searchResults){
this.listenToOnce(this.get("searchResults"), "sync", this.cacheSearchResults);
});
// Update the search model when the definition filters are updated.
// Definition filters may be updated by the user in the Query Builder,
// or they may be updated automatically by this model (e.g. when adding
// an isPartOf filter).
this.off("change:definition");
this.on("change:definition", function(){
this.stopListening(this.get("definition"), "update change");
this.listenTo(
this.get("definition"),
"update change",
this.updateSearchModel
);
}, this);
//Create a search model
this.set("searchModel", this.createSearchModel());
// Create a Filters collection to store the definition filters. Add the catalog
// search query fragment to the definition Filter Group model.
this.set("definition", new FilterGroup({ catalogSearch: true, nodeName: "definition" }));
this.set("definitionFilters", this.get("definition").get("filters"));
// Update the blocklist with the node/repository labels
var nodeBlockList = MetacatUI.appModel.get("portalLabelBlockList");
if (nodeBlockList !== undefined && Array.isArray(nodeBlockList)) {
this.set("labelBlockList", this.get("labelBlockList").concat(nodeBlockList));
}
},
/**
* updateSearchModel - This function is called when any changes are made to
* the definition filters. The function adds, removes, or updates models
* in the Search Model filters when models are changed in the collection
* Definition Filters.
*
* @param {Filter|Filters} model The model or collection that has been
* changed (filter models) or updated (filters collection). This is ignored.
* @param {object} record The data passed by backbone that indicates
* which models have been added, removed, or updated. If the only change was
* to a pre-existing model attribute, then the object will be empty.
*/
updateSearchModel: function(model, record){
try {
var model = this;
// Merge the updated definition Filter Group model with the Search Model collection.
this.get("searchModel").get("filters").add(
model.get("definition"),
{ merge: true }
);
} catch (e) {
console.log("Failed to update the Search Model collection when the " +
"Definition Model collection changed, error message: " + e);
}
},
/**
*
*
*/
url: function(){
return MetacatUI.appModel.get("objectServiceUrl") + encodeURIComponent(this.get("id"));
},
/**
* Overrides the default Backbone.Model.fetch() function to provide some custom
* fetch options
*
*/
fetch: function(){
var model = this;
var requestSettings = {
dataType: "xml",
error: function(){
model.trigger("error");
}
}
//Add the authorization header and other AJAX settings
requestSettings = _.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings());
return Backbone.Model.prototype.fetch.call(this, requestSettings);
},
/**
* Sends an AJAX request to fetch the system metadata for this object.
* Will not trigger a sync event since it does not use Backbone.Model.fetch
*/
fetchSystemMetadata: function(options){
if(!options) var options = {};
else options = _.clone(options);
//Get the active alternative repository, if one is configured
var activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
if( activeAltRepo ){
baseUrl = activeAltRepo.metaServiceUrl;
}
else{
baseUrl = MetacatUI.appModel.get("metaServiceUrl");
}
//Exit if no base URL was found
if( !baseUrl ){
return;
}
var model = this,
fetchOptions = _.extend({
url: baseUrl + encodeURIComponent(this.get("id") || this.get("seriesId")),
dataType: "text",
success: function(response){
model.set(DataONEObject.prototype.parse.call(model, response));
model.trigger("systemMetadataSync");
},
error: function(){
model.trigger('error');
}
}, options);
//Add the authorization header and other AJAX settings
_.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings());
$.ajax(fetchOptions);
},
/**
* Overrides the default Backbone.Model.parse() function to parse the custom
* collection XML document
*
* @param {XMLDocument} response - The XMLDocument returned from the fetch() AJAX call
* @return {JSON} The result of the parsed XML, in JSON. To be set directly on the model.
*/
parse: function(json){
//Start the empty JSON object
var modelJSON = {},
collectionNode;
//Iterate over each root XML node to find the collection node
$(response).children().each(function(i, el){
if( el.tagName.indexOf("collection") > -1 ){
collectionNode = el;
return false;
}
});
//If a collection XML node wasn't found, return an empty JSON object
if( typeof collectionNode == "undefined" || !collectionNode )
return {};
//Parse the collection XML and return it
return this.parseCollectionXML(collectionNode);
},
/**
* Parses the collection XML into a JSON object
*
* @param {Element} rootNode - The XML Element that contains all the collection nodes
* @return {JSON} The result of the parsed XML, in JSON. To be set directly on the model.
*/
parseCollectionXML: function( rootNode ){
// Get and save the namespace version number. It should be 1.0.0 or 1.1.0. Version
// 1.0.0 portals will be updated to 1.1.0 on save. We need to know which version
// while parsing to keep rendering of the old versions of collections/portals
// consistent with how they were rendered before MetacatUI was updated to handle
// 1.1.0 collections/portals - e.g. the fieldsOperator attribute in filters.
var namespace = rootNode.namespaceURI,
versionRegex = /\d\.\d\.\d$/g,
version = namespace.match(versionRegex);
if(version && version.length && version[0] != ""){
this.set("originalVersion", version[0])
}
var modelJSON = {};
//Parse the simple text nodes
modelJSON.name = this.parseTextNode(rootNode, "name");
modelJSON.label = this.parseTextNode(rootNode, "label");
modelJSON.description = this.parseTextNode(rootNode, "description");
//Create a Filters collection to contain the collection definition Filters
var definitionXML = rootNode.getElementsByTagName("definition")[0]
// Add the catalog search query fragment to the definition Filter Group model
modelJSON.definition = new FilterGroup({ objectDOM: definitionXML, catalogSearch: true });
modelJSON.definitionFilters = modelJSON.definition.get("filters")
//Create a Search model for this collection's filters
modelJSON.searchModel = this.createSearchModel();
// Add all the filters from the Collection definition to the search model as a single
// Filter Group model.
modelJSON.searchModel.get("filters").add(modelJSON.definition);
// If we are parsing the first version of a collection or portal
if(this.get("originalVersion") === "1.0.0"){
modelJSON = this.updateTo110(modelJSON);
}
return modelJSON
},
/**
* Takes parsed Collections 1.0.0 XML in JSON format and makes any changes required so
* that collections are still represented in MetacatUI as they were before MetacatUI
* was updated to support 1.1.0 Collections.
* @param {JSON} modelJSON Parsed 1.0.0 Collections XML, in JSON
* @return {JSON} The updated JSON, compatible with 1.1.0 changes
*/
updateTo110: function(modelJSON){
try {
// For version 1.0.0 filters, MetacatUI used the "operator" attribute to set the
// operator between both fields and values. In 1.1.0, we now have the
// "fieldsOperator" attribute. (Since "AND" was the default, only the "OR"
// operator is ever serialized). Therefore, if a version 1.0.0 filter has "OR" as
// the operator, we should also set the "fieldOperator" to "OR".
modelJSON.definitionFilters.each(function(filterModel){
if(filterModel.get("operator") === "OR"){
filterModel.set("fieldsOperator", "OR")
}
}, this);
return modelJSON
} catch (error) {
console.log("Error trying to update a 1.0.0 Collection to 1.1.0 " +
"returning the JSON unchanged. Error details: " + error );
return modelJSON
}
},
/**
* Generate a UUID, reserve it using the DataOne API, and set it on the model
*/
reserveSeriesId: function(){
// Create a new series ID
var seriesId = "urn:uuid:" + uuid.v4();
// Set the seriesId on the portal model right away, since reserving takes
// time. This will be updated in the rare case that the first seriesId was
// already taken.
this.set("seriesId", seriesId);
// Reserve a series ID for the new portal
var model = this;
var options = {
url: MetacatUI.appModel.get("reserveServiceUrl"),
type: "POST",
data: { pid: seriesId },
tryCount : 0,
// If a generated seriesId is already reserved, how many times to retry
retryLimit : 5,
success: function(xhr){
// If the first seriesId was taken, then update the model with the
// successfully reserved seriesId.
if(this.tryCount > 0) {
model.set("seriesId", $(xhr).find("identifier").text());
}
},
error : function(xhr) {
// If the seriesId was already reserved, try again
if (xhr.status == 409) {
this.tryCount++;
if (this.tryCount <= this.retryLimit) {
// Generate another seriesId
this.data = { pid:"urn:uuid:" + uuid.v4() };
// Send the reserve request again
$.ajax(this);
return;
}
return;
// If the user isn't logged in, or doesn't have write access
} else if (xhr.status = 401 ){
model.set("isAuthorized", false);
} else {
parsedResponse = $(xhr.responseText).not("style, title").text();
model.set("errorMessage", parsedResponse);
}
}
}
_.extend(options, MetacatUI.appUserModel.createAjaxSettings());
$.ajax(options);
},
/**
* Creates a FilterModel with isPartOf as the field and this collection's
* seriesId as the value, then adds it to the definitionFilters collection.
* (which will also add it to the searchFilters collection)
* @param {string} [seriesId] - the seriesId of the collection or portal
* @return {Filter} The new isPartOf filter that was created
*/
addIsPartOfFilter: function(seriesId){
try {
// If no seriesId is given
if(!seriesId){
// Use the seriesId set on the model
if(this.get("seriesId")){
seriesId = this.get("seriesId");
// If there's no seriesId on the model, make and reserve one
} else {
//Create and reserve a new seriesId
this.reserveSeriesId();
seriesId = this.get("seriesId");
// Set a listener to create an isPartOf filter using the seriesId once
// the series Id is set. Just in case the first seriesId generated was
// already reserved, update the isPartOf filters on the subsequent
// attempts to create and resere an ID.
this.on("change:seriesId", function(seriesId){
this.addIsPartOfFilter();
});
}
}
// Create the new isPartOf filter attributes object
// NOTE: All other attributes are set in Filter.initialize();
var isPartOfAttributes = {
fields: ["isPartOf"],
values: [seriesId],
matchSubstring: false,
operator: "OR"
};
// Remove any existing isPartOf filters, and add the new isPartOf filter
// NOTE:
// 1. Changes to the definition filters will automatically update the
// Search Model filters because of the listener set in initialize().
// 2. Add the new Filter model by passing a list of attributes to the
// Filters collection, instead of by passing a new Filter, in order
// to trigger 'update' and 'change' events for other models and views.
this.get("definitionFilters").removeFiltersByField("isPartOf");
var filterModel = this.get("definitionFilters").add(isPartOfAttributes);
return filterModel
} catch (e) {
console.log("Failed to create and add a new isPartOf Filter, error message: " + e);
}
},
/**
* Gets the text content of the XML node matching the given node name
*
* @param {Element} parentNode - The parent node to select from
* @param {string} nodeName - The name of the XML node to parse
* @param {boolean} isMultiple - If true, parses the nodes into an array
* @return {(string|Array)} - Returns a string or array of strings of the text content
*/
parseTextNode: function( parentNode, nodeName, isMultiple ){
var node = $(parentNode).children(nodeName);
//If no matching nodes were found, return falsey values
if( !node || !node.length ){
//Return an empty array if the isMultiple flag is true
if( isMultiple )
return [];
//Return null if the isMultiple flag is false
else
return null;
}
//If exactly one node is found and we are only expecting one, return the text content
else if( node.length == 1 && !isMultiple ){
return node[0].textContent.trim();
}
//If more than one node is found, parse into an array
else{
return _.map(node, function(node){
return node.textContent.trim() || null;
});
}
},
/**
* Updates collection XML with values in the collection model
*
* @param {XMLDocument} objectDOM the XML element to be updated
* @return {XMLElement} An updated XML element
*/
updateCollectionDOM: function(objectDOM){
// Get or make objectDOM
if(!objectDOM){
if (this.get("objectDOM")) {
var objectDOM = this.get("objectDOM").cloneNode(true);
$(objectDOM).empty();
} else {
// create an XML collection element from scratch
var objectDOM = $(this.createXML()).children()[0];
}
}
// Set schema version. May need to be updated from 1.0.0 to 1.1.0.
// The formatId is the same as the namespace URI.
var currentNamespace = this.defaults().formatId;
// The NS attribute name could be xmlns:por or xmlns:col
objectDOM.attributes.forEach(function(attr){
if(attr.name.match(/^xmlns/)){
if(attr.value !== currentNamespace){
var newObjectDOM = this.createXML().documentElement;
while (objectDOM.firstChild) {
newObjectDOM.appendChild(objectDOM.firstChild);
}
objectDOM = newObjectDOM
}
}
}, this);
// Remove definition node if it exists in XML already
$(objectDOM).find("definition").remove();
// Get the filters that are currently applied to the search.
var definitionSerialized = this.get("definition").updateDOM();
objectDOM.ownerDocument.adoptNode(definitionSerialized);
//If at least one filter was serialized, add the <definition> node
if (definitionSerialized.childNodes.length) {
$(objectDOM).prepend(definitionSerialized);
}
// Get and update the simple text strings (everything but definition)
// in reverse order because we prepend them consecutively to objectDOM
var collectionTextData = {
description: this.get("description"),
name: this.get("name"),
label: this.get("label")
}
_.map(collectionTextData, function(value, nodeName){
// Remove the node if it exists
// Use children() and not find() as there are sub-children named label
$(objectDOM).children(nodeName).remove();
// Don't serialize falsey values
if(value && (typeof value == "string" && value.trim().length)){
// Make new sub-node
var collectionSubnodeSerialized = objectDOM.ownerDocument.createElement(nodeName);
$(collectionSubnodeSerialized).text(value);
// Append new sub-node to the start of the objectDOM
$(objectDOM).prepend(collectionSubnodeSerialized);
}
});
return objectDOM;
},
/**
* Initialize the object XML for a brand spankin' new collection
* @return {Element}
*/
createXML: function() {
var xmlString = "<col:collection xmlns:col=\"https://purl.dataone.org/collections-1.1.0\"></col:collection>",
xmlNew = $.parseXML(xmlString),
colNode = xmlNew.getElementsByTagName("col:collections")[0];
// set attributes
colNode.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
colNode.setAttribute("xsi:schemaLocation", "https://purl.dataone.org/collections-1.1.0");
this.set("ownerDocument", colNode.ownerDocument);
return(xmlNew);
},
/**
* Creates a new instance of a Search model with a Filters collection.
* The Search model is created and returned and NOT set directly on the model in
* this function, because this function is called during parse(), when we're not ready
* to set attributes directly on the model yet.
* @return {Search}
*/
createSearchModel: function(){
var search = new Search();
// Do not set "catalogSearch" to true, even though the search model is specifically
// created in order to do a catalog search. Instead, we set the definition
// filterGroup model catalogSearch = true. This allows us to append the query
// fragment with ID fields AFTER the catalog query fragment.
search.set("filters", new Filters());
return search;
},
/**
* This is a shortcut function that returns the query for the datasets in this portal,
* using the Search model for this portal. This is the full query that includes the filters not
* serialized to the portal XML, such as the filters used for the DataCatalogView.
*
*/
getQuery: function(){
return this.get("definition").getQuery();
},
/**
* Creates a copy of the SolrResults collection and saves it in this
* model so that there is always access to the unfiltered list of datasets
*
* @param {SolrResults} searchResults - The SolrResults collection to cache
*/
cacheSearchResults: function(searchResults){
//Save a copy of the SolrResults so that we always have a copy of
// the unfiltered list of datasets
this.set("allSearchResults", searchResults.clone());
//Make a copy of the facetCounts object
var allSearchResults = this.get("allSearchResults");
allSearchResults.facetCounts = Object.assign({}, searchResults.facetCounts);
},
/**
* Overrides the default Backbone.Model.validate.function() to
* check if this portal model has all the required values necessary
* to save to the server.
*
* @param {Object} [attrs] - A literal object of model attributes to validate.
* @param {Object} [options] - A literal object of options for this validation process
* @return {Object} If there are errors, an object comprising error
* messages. If no errors, returns nothing.
*/
validate: function(attrs, options) {
try{
var errors = {};
// Validate label
var labelError = this.validateLabel(this.get("label"));
if( labelError ){
errors.label = labelError;
}
// Validate the definition
var definitionError = this.get("definition").validate(attrs, options);
if(definitionError){
if(definitionError.noFilters){
type = this.type.toLowerCase();
errors.definition = "Your dataset collection hasn't been created. Add at " +
"least one query rule below to find datasets for this " + type +
". For example, to create a " + type + " for datasets from a specific " +
"research project, try using the project name field.";
} else {
// Just show the first error for now.
errors.definition = Object.values(definitionError)[0]
}
}
if( Object.keys(errors).length ) {
console.log(errors);
return errors;
} else {
return;
}
}
catch(e){
console.error(e);
}
},
/**
* Checks that a label does not equal a restricted value
* (e.g. new temporary name), and that it's encoded properly
* for use as part of a url
*
* @param {string} label - The label to be validated
* @return {string} - If the label is invalid, an error message string is returned
*/
validateLabel: function(label){
try{
//Validate the label set on the model if one isn't given
if(typeof label != "string" ){
var label = this.get("label");
}
//If the label is not a string or is an empty string
if( typeof label != "string" || !label.trim().length ){
//Convert numbers to strings
if(typeof label == "number"){
label = label.toString();
}
else{
var type = this.type.toLowerCase();
return "Please choose a name for this " + type + " to use in the URL.";
}
}
// If the label is a restricted string
var blockList = this.get("labelBlockList");
if( blockList && Array.isArray(blockList) ){
if(blockList.includes(label)){
return "This URL is already taken, please try something else";
}
}
// If the label includes illegal characters
// (Only allow letters, numbers, underscores and dashes)
if(label.match(/[^A-Za-z0-9_-]/g)){
return "URLs may only contain letters, numbers, underscores (_), and dashes (-).";
}
}
catch(e){
//Trigger an error event
this.trigger("errorValidatingLabel");
console.error(e);
}
}
});
return CollectionModel;
});