diff --git a/src/js/collections/metadata/eml/EMLAnnotations.js b/src/js/collections/metadata/eml/EMLAnnotations.js index b29c17cfc..24461e0ca 100644 --- a/src/js/collections/metadata/eml/EMLAnnotations.js +++ b/src/js/collections/metadata/eml/EMLAnnotations.js @@ -1,75 +1,281 @@ "use strict"; -define([ - "jquery", - "underscore", - "backbone", - "models/metadata/eml211/EMLAnnotation", -], function ($, _, Backbone, EMLAnnotation) { +define(["underscore", "backbone", "models/metadata/eml211/EMLAnnotation"], ( + _, + Backbone, + EMLAnnotation, +) => { + const SCHEMA_ORG_SAME_AS = "http://www.w3.org/2002/07/owl#sameAs"; + const PROV_WAS_DERIVED_FROM = "http://www.w3.org/ns/prov#wasDerivedFrom"; /** * @class EMLAnnotations * @classdesc A collection of EMLAnnotations. * @classcategory Collections/Metadata/EML * @since 2.19.0 - * @extends Backbone.Collection + * @augments Backbone.Collection */ - var EMLAnnotations = Backbone.Collection.extend( + const EMLAnnotations = Backbone.Collection.extend( /** @lends EMLAnnotations.prototype */ { + /** @inheritdoc */ + model: EMLAnnotation, + /** - * The reference to the model class that this collection is made of. - * @type EMLAnnotation - * @since 2.19.0 + * Checks if this collection already has an annotation for the same + * property URI. + * @param {EMLAnnotation} annotation The EMLAnnotation to compare against + * the annotations already in this collection. + * @returns {boolean} Returns true is this collection already has an + * annotation for this property. */ - model: EMLAnnotation, + hasDuplicateOf(annotation) { + // If there is at least one model in this collection and there is a + // propertyURI set on the given model, + if (this.length && annotation.get("propertyURI")) { + // Return whether or not there is a duplicate + const properties = this.pluck("propertyURI"); + return properties.includes(annotation.get("propertyURI")); + } + // If this collection is empty or the propertyURI is falsey, return + // false + return false; + }, /** - * Checks if this collection already has an annotation for the same property URI. - * @param {EMLAnnotation} annotation The EMLAnnotation to compare against the annotations already in this collection. - * @returns {Boolean} Returns true is this collection already has an annotation for this property. - * @since 2.19.0 + * Find all annotations that have the same property & value URIs & labels. + * Only returns the models that are duplicates, not the original. The original + * is the first instance found in the collection. + * @returns {EMLAnnotation[]} An array of EMLAnnotations that are duplicates. + * @since 0.0.0 */ - hasDuplicateOf: function (annotation) { - try { - //If there is at least one model in this collection and there is a propertyURI set on the given model, - if (this.length && annotation.get("propertyURI")) { - //Return whether or not there is a duplicate - let properties = this.pluck("propertyURI"); - return properties.includes(annotation.get("propertyURI")); + getDuplicates() { + const groups = {}; + let duplicates = []; + + // Group models by their serialized attributes + this.each((model) => { + // Serialize the model's attributes to create a unique key + const key = JSON.stringify(model.attributes); + + // Group models by the serialized key + if (groups[key]) { + groups[key].push(model); + } else { + groups[key] = [model]; } - //If this collection is empty or the propertyURI is falsey, return false - else { - return false; + }); + + // Identify duplicates in each group + Object.values(groups).forEach((group) => { + if (group.length > 1) { + // Add all but one model from each group to the duplicates array + duplicates = duplicates.concat(group.slice(1)); } - } catch (e) { - console.error("Could not check for a duplicate annotation: ", e); - return false; + }); + + return duplicates; + }, + + /** + * Removes the EMLAnnotation from this collection that has the same + * propertyURI as the given annotation. Then adds the given annotation to + * the collection. If no duplicate is found, the given annotation is still + * added to the collection. + * @param {EMLAnnotation} annotation The EMLAnnotation to replace + * duplicates with. + */ + replaceDuplicateWith(annotation) { + if (this.length && annotation.get("propertyURI")) { + const duplicates = this.findWhere({ + propertyURI: annotation.get("propertyURI"), + }); + this.remove(duplicates); + } + this.add(annotation); + }, + + /** + * Find all annotations with the given propertyURI. + * @param {string} propertyURI The propertyURI to search for. + * @returns {EMLAnnotation[]} An array of EMLAnnotations with the given + * propertyURI. + * @since 0.0.0 + */ + findByProperty(propertyURI) { + return this.where({ propertyURI }); + }, + + /** + * Adds canonical dataset annotations to this collection. A canonical + * dataset is the one that is considered the authoritative version; the + * current EML doc being essentially a duplicate version. + * @param {string} sourceId The DOI or URL of the canonical dataset. + * @returns {void} + * @since 0.0.0 + */ + addCanonicalDatasetAnnotation(sourceId) { + if (!sourceId) return null; + return this.add([ + { + propertyLabel: "derivedFrom", + propertyURI: PROV_WAS_DERIVED_FROM, + valueLabel: sourceId, + valueURI: sourceId, + isCanonicalDataset: true, + }, + { + propertyLabel: "sameAs", + propertyURI: SCHEMA_ORG_SAME_AS, + valueLabel: sourceId, + valueURI: sourceId, + isCanonicalDataset: true, + }, + ]); + }, + + /** + * Find the annotations that make up the canonical dataset annotation. A + * canonical dataset is identified by having both a "derivedFrom" and a + * "sameAs" annotation with the same DOI or URL for the valueURI. + * @returns {object} An object with the derivedFrom and sameAs + * annotations. + * @since 0.0.0 + */ + findCanonicalDatasetAnnotation() { + // There must be at least one derivedFrom and one sameAs annotation + // for this to have a canonical dataset annotation + if (!this.length) return null; + const derivedFrom = this.findByProperty(PROV_WAS_DERIVED_FROM); + if (!derivedFrom?.length) return null; + const sameAs = this.findByProperty(SCHEMA_ORG_SAME_AS); + if (!sameAs?.length) return null; + + // Find all pairs that have matching valueURIs + const pairs = []; + derivedFrom.forEach((derived) => { + sameAs.forEach((same) => { + if (derived.get("valueURI") === same.get("valueURI")) { + pairs.push({ derived, same, uri: derived.get("valueURI") }); + } + }); + }); + + // If there are multiple pairs, we cannot determine which is the + // canonical dataset. + if (pairs.length > 1 || !pairs.length) return null; + + // There is only one pair left in this case + const canonAnnos = pairs[0]; + + // Make sure each annotation has the isCanonicalDataset flag set, + // we will use it later, e.g. in validation + canonAnnos.derived.set("isCanonicalDataset", true); + canonAnnos.same.set("isCanonicalDataset", true); + + return pairs[0]; + }, + + /** + * Updates the canonical dataset annotations to have the given ID. If + * there is no canonical dataset annotation, one is added. If the ID is a + * falsy value, the canonical dataset annotation is removed. + * @param {string} newSourceId The DOI or URL of the canonical dataset. + * @returns {object} An object with the derivedFrom and sameAs annotations + * if the canonical dataset annotations were updated. + * @since 0.0.0 + */ + updateCanonicalDataset(newSourceId) { + if (!newSourceId) { + this.removeCanonicalDatasetAnnotation(); + return null; + } + const canonical = this.findCanonicalDatasetAnnotation(); + if (!canonical) { + return this.addCanonicalDatasetAnnotation(newSourceId); } + + const { derived, same, uri } = canonical; + if (uri === newSourceId) return null; + + derived.set("valueURI", newSourceId); + derived.set("valueLabel", newSourceId); + same.set("valueURI", newSourceId); + same.set("valueLabel", newSourceId); + + return [derived, same]; }, /** - * Removes the EMLAnnotation from this collection that has the same propertyURI as the given annotation. - * Then adds the given annotation to the collection. If no duplicate is found, the given annotation is still added - * to the collection. - * @param {EMLAnnotation} annotation - * @since 2.19.0 + * Removes the canonical dataset annotations from this collection. + * @returns {EMLAnnotation[]} The canonical dataset annotations that were + * removed. + * @since 0.0.0 */ - replaceDuplicateWith: function (annotation) { - try { - if (this.length && annotation.get("propertyURI")) { - let duplicates = this.findWhere({ - propertyURI: annotation.get("propertyURI"), - }); - this.remove(duplicates); + removeCanonicalDatasetAnnotation() { + const canonical = this.findCanonicalDatasetAnnotation(); + if (!canonical) return null; + return this.remove([canonical.derived, canonical.same]); + }, + + /** + * Returns the URI of the canonical dataset. + * @returns {string} The URI of the canonical dataset. + * @since 0.0.0 + */ + getCanonicalURI() { + const canonical = this.findCanonicalDatasetAnnotation(); + return canonical?.uri; + }, + + /** @inheritdoc */ + validate() { + // Remove any totally empty annotations + this.remove(this.filter((annotation) => annotation.isEmpty())); + + // Remove annotations with the same value URI & property URI + const duplicates = this.getDuplicates(); + if (duplicates.length) { + this.remove(duplicates); + } + + // Validate each annotation + const errors = this.map((annotation) => annotation.validate()); + + // Remove any empty errors + const filteredErrors = errors.filter((error) => error); + + // Each annotation validation is an array, flatten them to one array + const flatErrors = [].concat(...filteredErrors); + + if (!filteredErrors.length) return null; + + // Put canonicalDataset annotation errors in their own category + // so they can be displayed in the special canonicalDataset field. + const canonicalErrors = []; + const errorsToRemove = []; + // Check for a canonicalDataset annotation error + flatErrors.forEach((annotationError, i) => { + if (annotationError.isCanonicalDataset) { + canonicalErrors.push(annotationError); + errorsToRemove.push(i); } + }); + + // Remove canonicalDataset errors from the annotation errors + // backwards so we don't mess up the indexes. + errorsToRemove.reverse().forEach((i) => { + flatErrors.splice(i, 1); + }); - this.add(annotation); - } catch (e) { - console.error( - "Could not replace the EMLAnnotation in the collection: ", - e, - ); + if (canonicalErrors.length) { + // The two canonicalDataset errors are the same, so just show one. + flatErrors.push({ + attr: "canonicalDataset", + message: canonicalErrors[0].message, + }); } + + return flatErrors; }, }, ); diff --git a/src/js/models/metadata/eml211/EML211.js b/src/js/models/metadata/eml211/EML211.js index 65c2a69ff..d4dfe724c 100644 --- a/src/js/models/metadata/eml211/EML211.js +++ b/src/js/models/metadata/eml211/EML211.js @@ -86,6 +86,7 @@ define([ methods: new EMLMethods(), // An EMLMethods objects project: null, // An EMLProject object, annotations: null, // Dataset-level annotations + canonicalDataset: null, dataSensitivityPropertyURI: "http://purl.dataone.org/odo/SENSO_00000005", nodeOrder: [ @@ -143,6 +144,13 @@ define([ this.set("synced", true); }); + this.stopListening(this, "change:canonicalDataset"); + this.listenTo( + this, + "change:canonicalDataset", + this.updateCanonicalDataset, + ); + //Create a Unit collection if (!this.units.length) this.createUnits(); }, @@ -160,6 +168,21 @@ define([ ); }, + /** + * Update the canonoical dataset URI in the annotations collection to + * match the canonicalDataset value on this model. + */ + updateCanonicalDataset() { + let uri = this.get("canonicalDataset"); + uri = uri?.length ? uri[0] : null; + let annotations = this.get("annotations"); + if (!annotations) { + annotations = new EMLAnnotations(); + this.set("annotations", annotations); + } + annotations.updateCanonicalDataset(uri); + }, + /* * Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML). * Used during parse() and serialize() @@ -734,6 +757,16 @@ define([ } } + // Once all the nodes have been parsed, check if any of the annotations + // make up a canonical dataset reference + const annotations = modelJSON["annotations"]; + if (annotations) { + const canonicalDataset = annotations.getCanonicalURI(); + if (canonicalDataset) { + modelJSON["canonicalDataset"] = canonicalDataset; + } + } + return modelJSON; }, @@ -1676,19 +1709,20 @@ define([ } } - // Validate each EMLAnnotation model - if (this.get("annotations")) { - this.get("annotations").each(function (model) { - if (model.isValid()) { - return; - } - - if (!errors.annotations) { - errors.annotations = []; - } + // Validate the EMLAnnotation models + const annotations = this.get("annotations"); + const annotationErrors = annotations.validate(); - errors.annotations.push(model.validationError); - }, this); + if (annotationErrors?.length) { + errors.annotations = annotationErrors.filter( + (e) => e.attr !== "canonicalDataset", + ); + const canonicalError = annotationErrors.find( + (e) => e.attr === "canonicalDataset", + ); + if (canonicalError) { + errors.canonicalDataset = canonicalError.message; + } } //Check the required fields for this MetacatUI configuration diff --git a/src/js/models/metadata/eml211/EMLAnnotation.js b/src/js/models/metadata/eml211/EMLAnnotation.js index 066af4687..ffb537bd0 100644 --- a/src/js/models/metadata/eml211/EMLAnnotation.js +++ b/src/js/models/metadata/eml211/EMLAnnotation.js @@ -23,7 +23,8 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { }, initialize: function (attributes, opions) { - this.on("change", this.trickleUpChange); + this.stopListening(this, "change", this.trickleUpChange); + this.listenTo(this, "change", this.trickleUpChange); }, parse: function (attributes, options) { @@ -54,65 +55,63 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { return attributes; }, - validate: function () { - var errors = []; + validate() { + const errors = []; if (this.isEmpty()) { this.trigger("valid"); - - return; - } - - var propertyURI = this.get("propertyURI"); - - if (!propertyURI || propertyURI.length <= 0) { - errors.push({ - category: "propertyURI", - message: "Property URI must be set.", - }); - } else if (propertyURI.match(/http[s]?:\/\/.+/) === null) { - errors.push({ - category: "propertyURI", - message: "Property URI should be an HTTP(S) URI.", - }); + return null; } - var propertyLabel = this.get("propertyLabel"); - - if (!propertyLabel || propertyLabel.length <= 0) { - errors.push({ - category: "propertyLabel", - message: "Property Label must be set.", - }); - } - - var valueURI = this.get("valueURI"); - - if (!valueURI || valueURI.length <= 0) { - errors.push({ - category: "valueURI", - message: "Value URI must be set.", - }); - } else if (valueURI.match(/http[s]?:\/\/.+/) === null) { - errors.push({ - category: "valueURI", - message: "Value URI should be an HTTP(S) URI.", - }); - } - - var valueLabel = this.get("valueLabel"); - - if (!valueLabel || valueLabel.length <= 0) { - errors.push({ - category: "valueLabel", - message: "Value Label must be set.", - }); - } + const isCanonicalDataset = this.get("isCanonicalDataset"); + + const emptyErrorMsg = (label) => `${label} must be set.`; + const uriErrorMsg = (label) => + `${label} should be an HTTP(S) URI, for example: http://example.com`; + + const isValidURI = (uri) => uri.match(/http[s]?:\/\/.+/) !== null; + + // Both URIs must be set and must be valid URIs + const uriAttrs = [ + { attr: "propertyURI", label: "Property URI" }, + { attr: "valueURI", label: "Value URI" }, + ]; + uriAttrs.forEach(({ attr, label }) => { + const uri = this.get(attr); + if (!uri || uri.length <= 0) { + errors.push({ + attr, + message: emptyErrorMsg(label), + isCanonicalDataset, + }); + } else if (!isValidURI(uri)) { + errors.push({ + attr, + message: uriErrorMsg(label), + isCanonicalDataset, + }); + } + }); + + // Both labels must be set to a string + const labelAttrs = [ + { attr: "propertyLabel", label: "Property Label" }, + { attr: "valueLabel", label: "Value Label" }, + ]; + labelAttrs.forEach(({ attr, label }) => { + const value = this.get(attr); + if (!value || value.length <= 0) { + errors.push({ + attr, + message: emptyErrorMsg(label), + isCanonicalDataset, + }); + } + }); if (errors.length === 0) { this.trigger("valid"); - - return; + return null; } return errors; @@ -175,7 +174,7 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) { /* Let the top level package know of attribute changes from this object */ trickleUpChange: function () { - MetacatUI.rootDataPackage.packageModel.set("changed", true); + MetacatUI.rootDataPackage.packageModel?.set("changed", true); }, }, ); diff --git a/src/js/templates/metadata/metadataOverview.html b/src/js/templates/metadata/metadataOverview.html index a8f117c8b..9a8c46ace 100644 --- a/src/js/templates/metadata/metadataOverview.html +++ b/src/js/templates/metadata/metadataOverview.html @@ -94,3 +94,11 @@
Alternate Identifiers