/* global define */ define(['jquery', 'underscore', 'backbone', 'uuid', 'he', 'collections/AccessPolicy', 'collections/ObjectFormats', 'md5'], function($, _, Backbone, uuid, he, AccessPolicy, ObjectFormats, md5){ /** * @class DataONEObject * @classdesc A DataONEObject represents a DataONE object, such as a data file, a science metadata object, or a resource map. It stores the system metadata attributes for the object, performs updates to the system metadata, and other basic DataONE API functions. This model can be extended to provide specific functionality for different object types, such as the {@link ScienceMetadata} model and the {@link EML211} model. * @classcategory Models * @augments Backbone.Model */ var DataONEObject = Backbone.Model.extend( /** @lends DataONEObject.prototype */{ type: "DataONEObject", selectedInEditor: false, // Has this package member been selected and displayed in the provenance editor? PROV: "http://www.w3.org/ns/prov#", PROVONE: "http://purl.dataone.org/provone/2015/01/15/ontology#", defaults: function(){ return{ // System Metadata attributes serialVersion: null, identifier: null, formatId: null, size: null, checksum: null, originalChecksum: null, checksumAlgorithm: "MD5", submitter: null, rightsHolder : null, accessPolicy: [], //An array of accessPolicy literal JS objects replicationAllowed: null, replicationPolicy: [], obsoletes: null, obsoletedBy: null, archived: null, dateUploaded: null, dateSysMetadataModified: null, originMemberNode: null, authoritativeMemberNode: null, replica: [], seriesId: null, // uuid.v4(), (decide if we want to auto-set this) mediaType: null, fileName: null, // Non-system metadata attributes: isNew: null, datasource: null, insert_count_i: null, read_count_i: null, changePermission: null, writePermission: null, readPermission: null, isPublic: null, dateModified: null, id: "urn:uuid:" + uuid.v4(), sizeStr: null, type: "", // Data, Metadata, or DataPackage formatType: "", metadataEntity: null, // A model that represents the metadata for this file, e.g. an EMLEntity model latestVersion: null, isDocumentedBy: null, documents: [], resourceMap: [], nodeLevel: 0, // Indicates hierarchy level in the view for indentation sortOrder: 2, // Metadata: 1, Data: 2, DataPackage: 3 synced: false, // True if the full model has been synced uploadStatus: null, //c=complete, p=in progress, q=queued, e=error, w=warning, no upload status=not in queue uploadProgress: null, sysMetaUploadStatus: null, //c=complete, p=in progress, q=queued, e=error, l=loading, no upload status=not in queue percentLoaded: 0, // Percent the file is read before caclculating the md5 sum uploadFile: null, // The file reference to be uploaded (JS object: File) errorMessage: null, sysMetaErrorCode: null, // The status code given when there is an error updating the system metadata numSaveAttempts: 0, notFound: false, //Whether or not this object was found in the system originalAttrs: [], // An array of original attributes in a DataONEObject changed: false, // If any attributes have been changed, including attrs in nested objects hasContentChanges: false, // If attributes outside of originalAttrs have been changed sysMetaXML: null, // A cached original version of the fetched system metadata document objectXML: null, // A cached version of the object fetched from the server isAuthorized: null, // If the stated permission is authorized by the user isAuthorized_read: null, //If the user has permission to read isAuthorized_write: null, //If the user has permission to write isAuthorized_changePermission: null, //If the user has permission to changePermission createSeriesId: false, //If true, a seriesId will be created when this object is saved. collections: [], //References to collections that this model is in possibleAuthMNs: [], //A list of possible authoritative MNs of this object useAltRepo: false, isLoadingFiles: false, //Only relevant to Resource Map objects. Is true if there is at least one file still loading into the package. numLoadingFiles: 0, //Only relevant to Resource Map objects. The number of files still loading into the package. provSources: [], provDerivations: [], prov_generated: [], prov_generatedByExecution: [], prov_generatedByProgram: [], prov_generatedByUser: [], prov_hasDerivations: [], prov_hasSources: [], prov_instanceOfClass: [], prov_used: [], prov_usedByExecution: [], prov_usedByProgram: [], prov_usedByUser: [], prov_wasDerivedFrom: [], prov_wasExecutedByExecution: [], prov_wasExecutedByUser: [], prov_wasInformedBy: [] } }, initialize: function(attrs, options) { if(typeof attrs == "undefined") var attrs = {}; this.set("accessPolicy", this.createAccessPolicy()); this.on("change:size", this.bytesToSize); if(attrs.size) this.bytesToSize(); // Cache an array of original attribute names to help in handleChange() if(this.type == "DataONEObject") this.set("originalAttrs", Object.keys(this.attributes)); else this.set("originalAttrs", Object.keys(DataONEObject.prototype.defaults())); this.on("successSaving", this.updateRelationships); //Save a reference to this DataONEObject model in the metadataEntity model //whenever the metadataEntity is set this.on("change:metadataEntity", function(){ var entityMetadataModel = this.get("metadataEntity"); if( entityMetadataModel ) entityMetadataModel.set("dataONEObject", this); }); this.on("sync", function(){ this.set("synced", true); }); //Find Member Node object that might be the authoritative MN //This is helpful when MetacatUI may be displaying content from multiple MNs this.setPossibleAuthMNs(); }, /** * Maps the lower-case sys meta node names (valid in HTML DOM) to the * camel-cased sys meta node names (valid in DataONE). * Used during parse() and serialize() */ nodeNameMap: function(){ return{ accesspolicy: "accessPolicy", accessrule: "accessRule", authoritativemembernode: "authoritativeMemberNode", checksumalgorithm: "checksumAlgorithm", dateuploaded: "dateUploaded", datesysmetadatamodified: "dateSysMetadataModified", formatid: "formatId", filename: "fileName", nodereference: "nodeReference", numberreplicas: "numberReplicas", obsoletedby: "obsoletedBy", originmembernode: "originMemberNode", replicamembernode: "replicaMemberNode", replicationallowed: "replicationAllowed", replicationpolicy: "replicationPolicy", replicationstatus: "replicationStatus", replicaverified: "replicaVerified", rightsholder: "rightsHolder", serialversion: "serialVersion", seriesid: "seriesId" }; }, /** * Returns the URL string where this DataONEObject can be fetched from or saved to * @returns {string} */ url: function(){ // With no id, we can't do anything if( !this.get("id") && !this.get("seriesid") ) return ""; //Get the active alternative repository, if one is configured var activeAltRepo = MetacatUI.appModel.getActiveAltRepo(); //Start the base URL string var baseUrl = ""; // Determine if we're updating a new/existing object, // or just its system metadata // New uploads use the object service URL if ( this.isNew() ) { //Use the object service URL from the alt repo if( this.get("useAltRepo") && activeAltRepo ){ baseUrl = activeAltRepo.objectServiceUrl; } //If this MetacatUI deployment is pointing to a MN, use the object service URL from the AppModel else{ baseUrl = MetacatUI.appModel.get("objectServiceUrl"); } //Return the full URL return baseUrl; } else { if ( this.hasUpdates() ) { if ( this.get("hasContentChanges") ) { //Use the object service URL from the alt repo if( this.get("useAltRepo") && activeAltRepo ){ baseUrl = activeAltRepo.objectServiceUrl; } else{ baseUrl = MetacatUI.appModel.get("objectServiceUrl"); } // Exists on the server, use MN.update() return baseUrl + (encodeURIComponent(this.get("oldPid"))); } else { //Use the meta service URL from the alt repo if( this.get("useAltRepo") && activeAltRepo ){ baseUrl = activeAltRepo.metaServiceUrl; } else{ baseUrl = MetacatUI.appModel.get("metaServiceUrl"); } // Exists on the server, use MN.updateSystemMetadata() return baseUrl + (encodeURIComponent(this.get("id"))); } } else { //Use the meta service URL from the alt repo if( this.get("useAltRepo") && activeAltRepo ){ baseUrl = activeAltRepo.metaServiceUrl; } else{ baseUrl = MetacatUI.appModel.get("metaServiceUrl"); } // Use MN.getSystemMetadata() return baseUrl + (encodeURIComponent(this.get("id")) || encodeURIComponent(this.get("seriesid"))); } } }, /** * Overload Backbone.Model.fetch, so that we can set custom options for each fetch() request */ fetch: function(options){ if ( ! options ) var options = {}; else var options = _.clone(options); options.url = this.url(); //If we are using the Solr service to retrieve info about this object, then construct a query if((typeof options != "undefined") && options.solrService){ //Get basic information var query = ""; //Do not search for seriesId when it is not configured in this model/app if(typeof this.get("seriesid") === "undefined") query += 'id:"' + encodeURIComponent(this.get("id")) + '"'; //If there is no seriesid set, then search for pid or sid else if(!this.get("seriesid")) query += '(id:"' + encodeURIComponent(this.get("id")) + '" OR seriesId:"' + encodeURIComponent(this.get("id")) + '")'; //If a seriesId is specified, then search for that else if(this.get("seriesid") && (this.get("id").length > 0)) query += '(seriesId:"' + encodeURIComponent(this.get("seriesid")) + '" AND id:"' + encodeURIComponent(this.get("id")) + '")'; //If only a seriesId is specified, then just search for the most recent version else if(this.get("seriesid") && !this.get("id")) query += 'seriesId:"' + encodeURIComponent(this.get("id")) + '" -obsoletedBy:*'; //The fields to return var fl = "formatId,formatType,documents,isDocumentedBy,id,seriesId"; //Use the Solr query URL var solrOptions = { url: MetacatUI.appModel.get("queryServiceUrl") + 'q=' + query + "&fl=" + fl + "&wt=json" } //Merge with the options passed to this function var fetchOptions = _.extend(options, solrOptions); } else if(typeof options != "undefined"){ //Use custom options for retreiving XML //Merge with the options passed to this function var fetchOptions = _.extend({ dataType: "text" }, options); } else{ //Use custom options for retreiving XML var fetchOptions = _.extend({ dataType: "text" }); } //Add the authorization options fetchOptions = _.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings()); //Call Backbone.Model.fetch to retrieve the info return Backbone.Model.prototype.fetch.call(this, fetchOptions); }, /** * This function is called by Backbone.Model.fetch. * It deserializes the incoming XML from the /meta REST endpoint and converts it into JSON. */ parse: function(response){ // If the response is XML if( (typeof response == "string") && response.indexOf("<") == 0 ) { var responseDoc = $.parseHTML(response), systemMetadata; //Save the raw XML in case it needs to be used later this.set("sysMetaXML", response); //Find the XML node for the system metadata for(var i=0; i -1)){ systemMetadata = responseDoc[i]; break; } } //Parse the XML to JSON var sysMetaValues = this.toJson(systemMetadata); //Convert the JSON to a camel-cased version, which matches Solr and is easier to work with in code _.each(Object.keys(sysMetaValues), function(key){ var camelCasedKey = this.nodeNameMap()[key]; if(camelCasedKey){ sysMetaValues[camelCasedKey] = sysMetaValues[key]; delete sysMetaValues[key]; } }, this); //Save the checksum from the system metadata in a separate attribute on the model sysMetaValues.originalChecksum = sysMetaValues.checksum; sysMetaValues.checksum = this.defaults().checksum; //Save the identifier as the id attribute sysMetaValues.id = sysMetaValues.identifier; //Parse the Access Policy if( this.get("accessPolicy") && AccessPolicy.prototype.isPrototypeOf(this.get("accessPolicy")) ){ this.get("accessPolicy").parse($(systemMetadata).find("accesspolicy")); sysMetaValues.accessPolicy = this.get("accessPolicy"); } else{ //Create a new AccessPolicy collection, if there isn't one already. sysMetaValues.accessPolicy = this.createAccessPolicy($(systemMetadata).find("accesspolicy")); } return sysMetaValues; // If the response is a list of Solr docs } else if (( typeof response === "object") && (response.response && response.response.docs)){ //If no objects were found in the index, mark as notFound and exit if(!response.response.docs.length){ this.set("notFound", true); this.trigger("notFound"); return; } //Get the Solr document (there should be only one) var doc = response.response.docs[0]; //Take out any empty values _.each(Object.keys(doc), function(field){ if( !doc[field] && doc[field] !== 0 ) delete doc[field]; }); //Remove any erroneous white space from fields this.removeWhiteSpaceFromSolrFields(doc); return doc; } else // Default to returning the raw response return response; }, /** A utility function for converting XML to JSON */ toJson: function(xml) { // Create the return object var obj = {}; // do children if (xml.hasChildNodes()) { for(var i = 0; i < xml.childNodes.length; i++) { var item = xml.childNodes.item(i); //If it's an empty text node, skip it if((item.nodeType == 3) && (!item.nodeValue.trim())) continue; //Get the node name var nodeName = item.localName; //If it's a new container node, convert it to JSON and add as a new object attribute if((typeof(obj[nodeName]) == "undefined") && (item.nodeType == 1)) { obj[nodeName] = this.toJson(item); } //If it's a new text node, just store the text value and add as a new object attribute else if((typeof(obj[nodeName]) == "undefined") && (item.nodeType == 3)){ obj = item.nodeValue == "false" ? false : item.nodeValue == "true" ? true : item.nodeValue; } //If this node name is already stored as an object attribute... else if(typeof(obj[nodeName]) != "undefined"){ //Cache what we have now var old = obj[nodeName]; if(!Array.isArray(old)) old = [old]; //Create a new object to store this node info var newNode = {}; //Add the new node info to the existing array we have now if(item.nodeType == 1){ newNode = this.toJson(item); var newArray = old.concat(newNode); } else if(item.nodeType == 3){ newNode = item.nodeValue; var newArray = old.concat(newNode); } //Store the attributes for this node _.each(item.attributes, function(attr){ newNode[attr.localName] = attr.nodeValue; }); //Replace the old array with the updated one obj[nodeName] = newArray; //Exit continue; } //Store the attributes for this node /*_.each(item.attributes, function(attr){ obj[nodeName][attr.localName] = attr.nodeValue; });*/ } } return obj; }, /** Serialize the DataONE object JSON to XML @param {object} json - the JSON object to convert to XML @param {Element} containerNode - an HTML element to insertt the resulting XML into @returns {Element} The updated HTML Element */ toXML: function(json, containerNode){ if(typeof json == "string"){ containerNode.textContent = json; return containerNode; } for(var i=0; i", "g"); xmlString = xmlString.replace(regEx, nodeNameMap[name] + ">"); //If node names haven't been changed, then find an attribute if(xmlString == originalXMLString){ var regEx = new RegExp(" " + name + "=", "g"); xmlString = xmlString.replace(regEx, " " + nodeNameMap[name] + "="); } }, this); xmlString = xmlString.replace(/systemmetadata/g, "systemMetadata"); return xmlString; }, /** * Get the object format identifier for this object */ getFormatId: function() { var formatId = "application/octet-stream", // default to untyped data objectFormats = { "mediaTypes": [], // The list of potential formatIds based on mediaType matches "extensions": [] // The list of possible formatIds based onextension matches }, fileName = this.get("fileName"), // the fileName for this object ext; // The extension of the filename for this object objectFormats["mediaTypes"] = MetacatUI.objectFormats.where({formatId: this.get("mediaType")}); if ( typeof fileName !== "undefined" && fileName !== null && fileName.length > 1) { ext = fileName.substring(fileName.lastIndexOf(".") + 1, fileName.length); objectFormats["extensions"] = MetacatUI.objectFormats.where({extension: ext}); } if (objectFormats["mediaTypes"].length > 0 && objectFormats["extensions"].length > 0) { var firstMediaType = objectFormats["mediaTypes"][0].get("formatId"); var firstExtension = objectFormats["extensions"][0].get("formatId"); // Check if they're equal if (firstMediaType === firstExtension) { formatId = firstMediaType; return formatId; } // Handle mismatched mediaType and extension cases - additional cases can be added below if (firstMediaType === 'application/vnd.ms-excel' && firstExtension === 'text/csv') { formatId = firstExtension; return formatId; } } if (objectFormats["mediaTypes"].length > 0) { formatId = objectFormats["mediaTypes"][0].get("formatId"); console.log('returning default mediaType'); console.log(formatId); return formatId; } if (objectFormats["extensions"].length > 0 ) { //If this is a "nc" file, assume it is a netCDF-3 file. if (ext == "nc") { formatId = "netCDF-3"; } else { formatId = objectFormats["extensions"][0].get("formatId"); } return formatId; } return formatId; }, /** * Build a fresh system metadata document for this object when it is new * Return it as a DOM object */ createSysMeta: function() { var sysmetaDOM, // The DOM sysmetaXML = []; // The document as a string array sysmetaXML.push( //'', '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '' ); sysmetaDOM = $($.parseHTML(sysmetaXML.join(""))); return sysmetaDOM; }, /** * Create an access policy for this DataONEObject using the default access * policy set in the AppModel. * * @param {Element} [accessPolicyXML] - An XML node * that contains a list of access rules. * @return {AccessPolicy} - an AccessPolicy collection that represents the * given XML or the default policy set in the AppModel. */ createAccessPolicy: function(accessPolicyXML){ //Create a new AccessPolicy collection var accessPolicy = new AccessPolicy(); accessPolicy.dataONEObject = this; //If there is no access policy XML sent, if( this.isNew() && !accessPolicyXML ){ try{ //If the app is configured to inherit the access policy from the parent metadata, // then get the parent metadata and copy it's AccessPolicy let scienceMetadata = this.get("isDocumentedByModels"); if( MetacatUI.appModel.get("inheritAccessPolicy") && scienceMetadata && scienceMetadata.length ){ let sciMetaAccessPolicy = scienceMetadata[0].get("accessPolicy"); if( sciMetaAccessPolicy ){ accessPolicy.copyAccessPolicy(sciMetaAccessPolicy); } else{ accessPolicy.createDefaultPolicy(); } } //Otherwise, set the default access policy using the AppModel configuration else{ accessPolicy.createDefaultPolicy(); } } catch(e){ console.error("Could create access policy, so defaulting to default", e); accessPolicy.createDefaultPolicy(); } } else{ //Parse the access policy XML to create AccessRule models from the XML accessPolicy.parse(accessPolicyXML); } //Listen to changes on the collection and trigger a change on this model var self = this; this.listenTo(accessPolicy, "change update", function(){ self.trigger("change"); this.addToUploadQueue(); }); return accessPolicy; }, /** * Update identifiers for this object * * @param {string} id - Optional identifier to update with. Generated * automatically when not given. * * Note that this method caches the objects attributes prior to * updating so this.resetID() can be called in case of a failure * state. * * Also note that this method won't run if theh oldPid attribute is * set. This enables knowing before this.save is called what the next * PID will be such as the case where we want to update a matching * EML entity when replacing files. */ updateID: function(id){ // Only run once until oldPid is reset if (this.get("oldPid")) { return; } //Save the attributes so we can reset the ID later this.attributeCache = this.toJSON(); //Set the old identifier var oldPid = this.get("id"), selfDocuments, selfDocumentedBy, documentedModels, documentedModel, index; //Save the current id as the old pid this.set("oldPid", oldPid); //Create a new seriesId, if there isn't one, and if this model specifies that one is required if( !this.get("seriesId") && this.get("createSeriesId") ){ this.set("seriesId", "urn:uuid:" + uuid.v4()); } // Check to see if the old pid documents or is documented by itself selfDocuments = _.contains(this.get("documents"), oldPid); selfDocumentedBy = _.contains(this.get("isDocumentedBy"), oldPid); //Set the new identifier if( id ) { this.set("id", id); } else { if( this.get("type") == "DataPackage" ){ this.set("id", "resource_map_urn:uuid:" + uuid.v4()); } else{ this.set("id", "urn:uuid:" + uuid.v4()); } } // Remove the old pid from the documents list if present if ( selfDocuments ) { index = this.get("documents").indexOf(oldPid); if ( index > -1 ) { this.get("documents").splice(index, 1); } // And add the new pid in this.get("documents").push(this.get("id")); } // Remove the old pid from the isDocumentedBy list if present if ( selfDocumentedBy ) { index = this.get("isDocumentedBy").indexOf(oldPid); if ( index > -1 ) { this.get("isDocumentedBy").splice(index, 1); } // And add the new pid in this.get("isDocumentedBy").push(this.get("id")); } // Update all models documented by this pid with the new id _.each(this.get("documents"), function(id) { documentedModels = MetacatUI.rootDataPackage.where({id: id}), documentedModel; if ( documentedModels.length > 0 ) { documentedModel = documentedModels[0]; } if ( typeof documentedModel !== "undefined" ) { // Find the oldPid in the array if( Array.isArray(documentedModel.get("isDocumentedBy")) ){ index = documentedModel.get("isDocumentedBy").indexOf("oldPid"); if ( index > -1 ) { // Remove it documentedModel.get("isDocumentedBy").splice(index, 1); } // And add the new pid in documentedModel.get("isDocumentedBy").push(this.get("id")); } } }, this); this.trigger("change:id") //Update the obsoletes and obsoletedBy this.set("obsoletes", oldPid); this.set("obsoletedBy", null); // Update the latest version of this object this.set("latestVersion", this.get("id")); //Set the archived option to false this.set("archived", false); }, /** * Resets the identifier for this model. This undos all of the changes made in {DataONEObject#updateID} */ resetID: function(){ if(!this.attributeCache) return false; this.set("oldPid", this.attributeCache.oldPid, {silent:true}); this.set("id", this.attributeCache.id, {silent: true}); this.set("obsoletes", this.attributeCache.obsoletes, {silent: true}); this.set("obsoletedBy", this.attributeCache.obsoletedBy, {silent: true}); this.set("archived", this.attributeCache.archived, {silent: true}); this.set("latestVersion", this.attributeCache.latestVersion, {silent: true}); //Reset the attribute cache this.attributeCache = {}; }, /** * Checks if this system metadata XML has updates that need to be synced with the server. * @returns {boolean} */ hasUpdates: function(){ if(this.isNew()) return true; // Compare the new system metadata XML to the old system metadata XML //Check if there is system metadata first if( !this.get("sysMetaXML") ){ return false; } var D1ObjectClone = this.clone(), // Make sure we are using the parse function in the DataONEObject model. // Sometimes hasUpdates is called from extensions of the D1Object model, // (e.g. from the portal model), and the parse function is overwritten oldSysMetaAttrs = new DataONEObject().parse(D1ObjectClone.get("sysMetaXML")); D1ObjectClone.set(oldSysMetaAttrs); var oldSysMeta = D1ObjectClone.serializeSysMeta(); var newSysMeta = this.serializeSysMeta(); if ( oldSysMeta === "" ) return false; return !(newSysMeta == oldSysMeta); }, /** Set the changed flag on any system metadata or content attribute changes, and set the hasContentChanges flag on content changes only @param {DataONEObject} [model] @param {object} options Furhter options for this function @property {boolean} options.force If true, a change will be handled regardless if the attribute actually changed */ handleChange: function(model, options) { if(!model) var model = this; var sysMetaAttrs = ["serialVersion", "identifier", "formatId", "formatType", "size", "checksum", "checksumAlgorithm", "submitter", "rightsHolder", "accessPolicy", "replicationAllowed", "replicationPolicy", "obsoletes", "obsoletedBy", "archived", "dateUploaded", "dateSysMetadataModified", "originMemberNode", "authoritativeMemberNode", "replica", "seriesId", "mediaType", "fileName"], nonSysMetaNonContentAttrs = _.difference(model.get("originalAttrs"), sysMetaAttrs), allChangedAttrs = Object.keys(model.changedAttributes()), changedSysMetaOrContentAttrs = [], //sysmeta or content attributes that have changed changedContentAttrs = []; // attributes from sub classes like ScienceMetadata or EML211 ... // Get a list of all changed sysmeta and content attributes changedSysMetaOrContentAttrs = _.difference(allChangedAttrs, nonSysMetaNonContentAttrs); if ( changedSysMetaOrContentAttrs.length > 0 ) { // For any sysmeta or content change, set the package dirty flag if ( MetacatUI.rootDataPackage && MetacatUI.rootDataPackage.packageModel && ! MetacatUI.rootDataPackage.packageModel.get("changed") && model.get("synced") ) { MetacatUI.rootDataPackage.packageModel.set("changed", true); } } // And get a list of all changed content attributes changedContentAttrs = _.difference(changedSysMetaOrContentAttrs, sysMetaAttrs); if ( (changedContentAttrs.length > 0 && !this.get("hasContentChanges") && model.get("synced")) || (options && options.force)) { this.set("hasContentChanges", true); this.addToUploadQueue(); } }, /** * Returns true if this DataONE object is new. A DataONE object is new * if there is no upload date and it's been synced (i.e. been fetched) * @return {boolean} */ isNew: function(){ //If the model is explicitly marked as not new, return false if( this.get("isNew") === false ){ return false; } //If the model is explicitly marked as new, return true else if( this.get("isNew") === true ){ return true; } //Check if there is an upload date that was retrieved from the server return ( this.get("dateUploaded") === this.defaults().dateUploaded && this.get("synced") ); }, /** * Updates the upload status attribute on this model and marks the collection as changed */ addToUploadQueue: function(){ if( !this.get("synced") ){ return; } //Add this item to the queue if((this.get("uploadStatus") == "c") || (this.get("uploadStatus") == "e") || !this.get("uploadStatus")){ this.set("uploadStatus", "q"); //Mark each DataPackage collection this model is in as changed _.each(this.get("collections"), function(collection){ if(collection.packageModel) collection.packageModel.set("changed", true); }, this); } }, /** * Updates the progress percentage when the model is getting uploaded * @param {ProgressEvent} e - The ProgressEvent when this file is being uploaded */ updateProgress: function(e){ if(e.lengthComputable){ var max = e.total; var current = e.loaded; var Percentage = (current * 100)/max; if(Percentage >= 100) { // process completed } } }, /** * Updates the relationships with other models when this model has been updated */ updateRelationships: function(){ _.each(this.get("collections"), function(collection){ //Get the old id for this model var oldId = this.get("oldPid"); if(!oldId) return; //Find references to the old id in the documents relationship var outdatedModels = collection.filter(function(m){ return _.contains(m.get("documents"), oldId); }); //Update the documents array in each model _.each(outdatedModels, function(model){ var updatedDocuments = _.without(model.get("documents"), oldId); updatedDocuments.push(this.get("id")); model.set("documents", updatedDocuments); }, this); }, this); }, /** * Finds the latest version of this object by travesing the obsolescence chain * @param {string} [latestVersion] - The identifier of the latest known object in the version chain. If not supplied, this model's `id` will be used. * @param {string} [possiblyNewer] - The identifier of the object that obsoletes the latestVersion. It's "possibly" newer, because it may be private/inaccessible */ findLatestVersion: function(latestVersion, possiblyNewer){ var baseUrl = "", activeAltRepo = MetacatUI.appModel.getActiveAltRepo(); //Use the meta service URL from the alt repo if( activeAltRepo ){ baseUrl = activeAltRepo.metaServiceUrl; } //If this MetacatUI deployment is pointing to a MN, use the meta service URL from the AppModel else{ baseUrl = MetacatUI.appModel.get("metaServiceUrl"); } if( !baseUrl ){ return; } //If there is no system metadata, then retrieve it first if(!this.get("sysMetaXML")){ this.once("sync", this.findLatestVersion); this.once("systemMetadataSync", this.findLatestVersion); this.fetch({ url: baseUrl + encodeURIComponent(this.get("id")), dataType: "text", systemMetadataOnly: true }); return; } //If no pid was supplied, use this model's id if(!latestVersion || typeof latestVersion != "string"){ var latestVersion = this.get("id"); var possiblyNewer = this.get("obsoletedBy"); } //If this isn't obsoleted by anything, then there is no newer version if(!possiblyNewer || typeof latestVersion != "string"){ this.set("latestVersion", latestVersion); //Trigger an event that will fire whether or not the latestVersion // attribute was actually changed this.trigger("latestVersionFound", this); //Remove the listeners now that we found the latest version this.stopListening("sync", this.findLatestVersion); this.stopListening("systemMetadataSync", this.findLatestVersion); return; } var model = this; //Get the system metadata for the possibly newer version var requestSettings = { url: baseUrl + encodeURIComponent(possiblyNewer), type: "GET", success: function(data) { // the response may have an obsoletedBy element var obsoletedBy = $(data).find("obsoletedBy").text(); //If there is an even newer version, then get it and rerun this function if(obsoletedBy){ model.findLatestVersion(possiblyNewer, obsoletedBy); } //If there isn't a newer version, then this is it else{ model.set("latestVersion", possiblyNewer); model.trigger("latestVersionFound", model); //Remove the listeners now that we found the latest version model.stopListening("sync", model.findLatestVersion); model.stopListening("systemMetadataSync", model.findLatestVersion); } }, error: function(xhr){ //If this newer version isn't accessible, link to the latest version that is if(xhr.status == "401"){ model.set("latestVersion", latestVersion); model.trigger("latestVersionFound", model); } } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, /** * A utility function that will format an XML string or XML nodes by camel-casing the node names, as necessary * @param {string|Element} xml - The XML to format * @returns {string} The formatted XML string */ formatXML: function(xml){ var nodeNameMap = this.nodeNameMap(), xmlString = ""; //XML must be provided for this function if(!xml) return ""; //Support XML strings else if(typeof xml == "string") xmlString = xml; //Support DOMs else if(typeof xml == "object" && xml.nodeType){ //XML comments should be formatted with start and end carets if(xml.nodeType == 8) xmlString = "<" + xml.nodeValue + ">"; //XML nodes have the entire XML string available in the outerHTML attribute else if(xml.nodeType == 1) xmlString = xml.outerHTML; //Text node types are left as-is else if(xml.nodeType == 3) return xml.nodeValue; } //Return empty strings if something went wrong if(!xmlString) return ""; _.each(Object.keys(nodeNameMap), function(name, i){ var originalXMLString = xmlString; //Check for this node name whe it's an opening XML node, e.g. `` var regEx = new RegExp("<" + name + ">", "g"); xmlString = xmlString.replace(regEx, "<" + nodeNameMap[name] + ">"); //Check for this node name when it's an opening XML node, e.g. `` regEx = new RegExp(":" + name + ">", "g"); xmlString = xmlString.replace(regEx, ":" + nodeNameMap[name] + ">"); //Check for this node name when it's a closing XML tag, e.g. `` regEx = new RegExp("", "g"); xmlString = xmlString.replace(regEx, ""); //If node names haven't been changed, then find an attribute, e.g. ` name=` if(xmlString == originalXMLString){ regEx = new RegExp(" " + name + "=", "g"); xmlString = xmlString.replace(regEx, " " + nodeNameMap[name] + "="); } }, this); //Take each XML node text value and decode any XML entities var regEx = new RegExp("\&[0-9a-zA-Z]+\;", "g"); xmlString = xmlString.replace(regEx, function(match){ return he.encode(he.decode(match)); }); return xmlString; }, /** * Converts the number of bytes into a human readable format and updates the `sizeStr` attribute */ bytesToSize: function(){ var kilobyte = 1024; var megabyte = kilobyte * 1024; var gigabyte = megabyte * 1024; var terabyte = gigabyte * 1024; var precision = 0; var bytes = this.get("size"); if ((bytes >= 0) && (bytes < kilobyte)) { this.set("sizeStr", bytes + ' B'); } else if ((bytes >= kilobyte) && (bytes < megabyte)) { this.set("sizeStr", (bytes / kilobyte).toFixed(precision) + ' KB'); } else if ((bytes >= megabyte) && (bytes < gigabyte)) { precision = 2; this.set("sizeStr", (bytes / megabyte).toFixed(precision) + ' MB'); } else if ((bytes >= gigabyte) && (bytes < terabyte)) { precision = 2; this.set("sizeStr", (bytes / gigabyte).toFixed(precision) + ' GB'); } else if (bytes >= terabyte) { precision = 2; this.set("sizeStr", (bytes / terabyte).toFixed(precision) + ' TB'); } else { this.set("sizeStr", bytes + ' B'); } }, /** * Creates a file name for this DataONEObject and updates the `fileName` attribute */ setMissingFileName: function() { var objectFormats, filename, extension; objectFormats = MetacatUI.objectFormats.where({formatId: this.get("formatId")}); if ( objectFormats.length > 0 ) { extension = objectFormats[0].get("extension"); } //Science metadata file names will use the title if( this.get("type") == "Metadata" ){ filename = (Array.isArray(this.get("title")) && this.get("title").length)? this.get("title")[0] : this.get("id"); } //Resource maps will use a "resource_map_" prefix else if( this.get("type") == "DataPackage" ){ filename = "resource_map_" + this.get("id"); extension = ".rdf.xml"; } //All other object types will just use the id else{ filename = this.get("id"); } //Replace all non-alphanumeric characters with underscores filename = filename.replace(/[^a-zA-Z0-9]/g, "_"); if ( typeof extension !== "undefined" ) { filename = filename + "." + extension; } this.set("fileName", filename); }, /** * Creates a URL for viewing more information about this object * @return {string} */ createViewURL: function(){ return MetacatUI.root + "/view/" + encodeURIComponent((this.get("seriesId") || this.get("id"))); }, /** * Converts the identifier string to a string safe to use in an XML id attribute * @param {string} [id] - The ID string * @return {string} - The XML-safe string */ getXMLSafeID: function(id){ if(typeof id == "undefined"){ var id = this.get("id"); } //Replace XML id attribute invalid characters and patterns in the identifier id = id.replace(/ -1); }); if((typeof programClass !== "undefined") && programClass.length) return "program"; } else{ if(this.get("prov_generated").length || this.get("prov_used").length) return "program"; } //Determine the type via file format if(this.isSoftware()) return "program"; if(this.isData()) return "data"; if(this.get("type").toLowerCase() == "metadata") return "metadata"; if(this.isImage()) return "image"; if(_.contains(pdfIds, this.get("formatId"))) return "PDF"; if(_.contains(annotationIds, this.get("formatId"))) return "annotation"; else return "data"; }, /** * Checks the formatId of this model and determines if it is an image. * @returns {boolean} true if this data object is an image, false if it is other */ isImage: function(){ //The list of formatIds that are images var imageIds = ["image/gif", "image/jp2", "image/jpeg", "image/png"]; //Does this data object match one of these IDs? if(_.indexOf(imageIds, this.get('formatId')) == -1) return false; else return true; }, /** * Checks the formatId of this model and determines if it is a data file. * This determination is mostly used for display and the provenance editor. In the * DataONE API, many formatIds are considered `DATA` formatTypes, but they are categorized * as images {@link DataONEObject#isImage} or software {@link DataONEObject#isSoftware}. * @returns {boolean} true if this data object is a data file, false if it is other */ isData: function() { var dataIds = ["application/atom+xml", "application/mathematica", "application/msword", "application/netcdf", "application/octet-stream", "application/pdf", "application/postscript", "application/rdf+xml", "application/rtf", "application/vnd.google-earth.kml+xml", "application/vnd.ms-excel", "application/vnd.ms-excel.sheet.binary.macroEnabled.12", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/x-bzip2", "application/x-fasta", "application/x-gzip", "application/x-rar-compressed", "application/x-tar", "application/xhtml+xml", "application/xml", "application/zip", "audio/mpeg", "audio/x-ms-wma", "audio/x-wav", "image/svg xml", "image/svg+xml", "image/bmp", "image/tiff", "text/anvl", "text/csv", "text/html", "text/n3", "text/plain", "text/tab-separated-values", "text/turtle", "text/xml", "video/avi", "video/mp4", "video/mpeg", "video/quicktime", "video/x-ms-wmv"]; //Does this data object match one of these IDs? if(_.indexOf(dataIds, this.get('formatId')) == -1) return false; else return true; }, /** * Checks the formatId of this model and determines if it is a software file. * This determination is mostly used for display and the provenance editor. In the * DataONE API, many formatIds are considered `DATA` formatTypes, but they are categorized * as images {@link DataONEObject#isImage} for display purposes. * @returns {boolean} true if this data object is a software file, false if it is other */ isSoftware: function(){ //The list of formatIds that are programs var softwareIds = ["text/x-python", "text/x-rsrc", "text/x-matlab", "text/x-sas", "application/R", "application/x-ipynb+json"]; //Does this data object match one of these IDs? if(_.indexOf(softwareIds, this.get('formatId')) == -1) return false; else return true; }, /** * Checks the formatId of this model and determines if it a PDF. * @returns {boolean} true if this data object is a pdf, false if it is other */ isPDF: function(){ //The list of formatIds that are images var ids = ["application/pdf"]; //Does this data object match one of these IDs? if(_.indexOf(ids, this.get('formatId')) == -1) return false; else return true; }, /** * Set the DataONE ProvONE provenance class * param className - the shortened form of the actual classname value. The * shortname will be appened to the ProvONE namespace, for example, * the className "program" will result in the final class name * "http://purl.dataone.org/provone/2015/01/15/ontology#Program" * see https://github.com/DataONEorg/sem-prov-ontologies/blob/master/provenance/ProvONE/v1/provone.html * @param {string} className */ setProvClass: function(className) { className = className.toLowerCase(); className = className.charAt(0).toUpperCase() + className.slice(1) /* This function is intended to be used for the ProvONE classes that are * typically represented in DataONEObjects: "Data", "Program", and hopefully * someday "Execution", as we don't allow the user to set the namespace * e.g. to "PROV", so therefor we check for the currently known ProvONE classes. */ if (_.contains(['Program', 'Data', 'Visualization', 'Document', 'Execution', 'User'], className)) { this.set("prov_instanceOfClass", [this.PROVONE + className]); } else if (_.contains(['Entity', 'Usage', 'Generation', 'Association'], className)) { this.set("prov_instanceOfClass", [this.PROV + className]); } else { message = "The given class name: " + className + " is not in the known ProvONE or PROV classes." throw new Error(message); } }, /** * Calculate a checksum for the object * @param {string} [algorithm] The algorithm to use, defaults to MD5 * @return {string} A checksum plain JS object with value and algorithm attributes */ calculateChecksum: function(algorithm) { var algorithm = algorithm || "MD5"; var checksum = {algorithm: undefined, value: undefined}; var hash; // The checksum hash var file; // The file to be read by slicing var reader; // The FileReader used to read each slice var offset = 0; // Byte offset for reading slices var sliceSize = Math.pow(2,20) // 1MB slices var model = this; // Do we have a file? if (this.get("uploadFile") instanceof Blob) { file = this.get("uploadFile"); reader = new FileReader(); /* Handle load errors */ reader.onerror = function(event) { console.log("Error reading: " + event); }; /* Show progress */ reader.onprogress = function(event) { }; /* Handle load finish */ reader.onloadend = function(event) { if (event.target.readyState == FileReader.DONE) { hash.update(event.target.result); } offset += sliceSize; if ( _seek() ) { model.set("checksum", hash.hex()); model.set("checksumAlgorithm", checksum.algorithm); model.trigger("checksumCalculated", model.attributes); }; }; } else { message = "The given object is not a blob or a file object." throw new Error(message); } switch ( algorithm ) { case "MD5": checksum.algorithm = algorithm; hash = md5.create(); _seek(); break; case "SHA-1": // TODO: Support SHA-1 // break; default: message = "The given algorithm: " + algorithm + " is not supported." throw new Error(message); } /* * A helper function internal to calculateChecksum() used to slice * the file at the next offset by slice size */ function _seek() { var calculated = false; var slice; // Digest the checksum when we're done calculating if (offset >= file.size) { hash.digest(); calculated = true; return calculated; } // slice the file and read the slice slice = file.slice(offset, offset + sliceSize); reader.readAsArrayBuffer(slice); return calculated; } }, /** * Checks if the pid or sid or given string is a DOI * * @param {string} customString - Optional. An identifier string to check instead of the id and seriesId attributes on the model * @returns {boolean} True if it is a DOI */ isDOI: function(customString) { var DOI_PREFIXES = ["doi:10.", "http://dx.doi.org/10.", "http://doi.org/10.", "http://doi.org/doi:10.", "https://dx.doi.org/10.", "https://doi.org/10.", "https://doi.org/doi:10."], DOI_REGEX = new RegExp(/^10.\d{4,9}\/[-._;()/:A-Z0-9]+$/i);; //If a custom string is given, then check that instead of the seriesId and id from the model if( typeof customString == "string" ){ for (var i=0; i < DOI_PREFIXES.length; i++) { if (customString.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) return true; } //If there is no DOI prefix, check for a DOI without the prefix using a regular expression if( DOI_REGEX.test(customString) ){ return true; } } else{ var seriesId = this.get("seriesId"), pid = this.get("id"); for (var i=0; i < DOI_PREFIXES.length; i++) { if (seriesId && seriesId.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) return true; else if (pid && pid.toLowerCase().indexOf(DOI_PREFIXES[i].toLowerCase()) == 0 ) return true; } //If there is no DOI prefix, check for a DOI without the prefix using a regular expression if( DOI_REGEX.test(seriesId) || DOI_REGEX.test(pid) ){ return true; } } return false; }, /** * Creates an array of objects that represent Member Nodes that could possibly be this * object's authoritative MN. This function updates the `possibleAuthMNs` attribute on this model. */ setPossibleAuthMNs: function(){ //Only do this for Coordinating Node MetacatUIs. if( MetacatUI.appModel.get("alternateRepositories").length ){ //Set the possibleAuthMNs attribute var possibleAuthMNs = []; //If a datasource is already found for this Portal, move that to the top of the list of auth MNs var datasource = this.get("datasource") || ""; if( datasource ){ //Find the MN object that matches the datasource node ID var datasourceMN = _.findWhere(MetacatUI.appModel.get("alternateRepositories"), { identifier: datasource }); if( datasourceMN ){ //Clone the MN object and add it to the array var clonedDatasourceMN = Object.assign({}, datasourceMN); possibleAuthMNs.push(clonedDatasourceMN); } } //If there is an active alternate repo, move that to the top of the list of auth MNs var activeAltRepo = MetacatUI.appModel.get("activeAlternateRepositoryId") || ""; if( activeAltRepo ){ var activeAltRepoMN = _.findWhere(MetacatUI.appModel.get("alternateRepositories"), { identifier: activeAltRepo }); if( activeAltRepoMN ){ //Clone the MN object and add it to the array var clonedActiveAltRepoMN = Object.assign({}, activeAltRepoMN); possibleAuthMNs.push(clonedActiveAltRepoMN); } } //Add all the other alternate repositories to the list of auth MNs var otherPossibleAuthMNs = _.reject(MetacatUI.appModel.get("alternateRepositories"), function(mn){ return (mn.identifier == datasource || mn.identifier == activeAltRepo); }); //Clone each MN object and add to the array _.each(otherPossibleAuthMNs, function(mn){ var clonedMN = Object.assign({}, mn); possibleAuthMNs.push(clonedMN); }); //Update this model this.set("possibleAuthMNs", possibleAuthMNs); } }, /** * Removes white space from string values returned by Solr when the white space causes issues. * For now this only effects the `resourceMap` field, which will index new line characters and spaces * when the RDF XML has those in the `identifier` XML element content. This was causing bugs where DataONEObject * models were created with `id`s with new line and white space characters (e.g. `\n urn:uuid:1234...`) * @param {object} json - The Solr document as a JS Object, which will be directly altered */ removeWhiteSpaceFromSolrFields: function(json){ if( typeof json.resourceMap == "string" ){ json.resourceMap = json.resourceMap.trim(); } else if( Array.isArray(json.resourceMap) ){ let newResourceMapIds = []; _.each(json.resourceMap, function(rMapId){ if( typeof rMapId == "string" ){ newResourceMapIds.push(rMapId.trim()); } }); json.resourceMap = newResourceMapIds; } } }, /** @lends DataONEObject.prototype */ { /** * Generate a unique identifier to be used as an XML id attribute * @returns {string} The identifier string that was generated */ generateId: function() { var idStr = ''; // the id to return var length = 30; // the length of the generated string var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz'.split(''); for (var i = 0; i < length; i++) { idStr += chars[Math.floor(Math.random() * chars.length)]; } return idStr; } }); return DataONEObject; });