Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Allow adding, removing, and changing the "canonical dataset" in the Editor #2551

Merged
merged 11 commits into from
Dec 18, 2024
218 changes: 167 additions & 51 deletions src/js/collections/metadata/eml/EMLAnnotations.js
Original file line number Diff line number Diff line change
@@ -1,75 +1,191 @@
"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
* 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.
*/
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"));
}
//If this collection is empty or the propertyURI is falsey, return false
else {
return false;
}
} catch (e) {
console.error("Could not check for a duplicate annotation: ", e);
return false;
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 });
},

/**
* 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
* 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
*/
replaceDuplicateWith: function (annotation) {
try {
if (this.length && annotation.get("propertyURI")) {
let duplicates = this.findWhere({
propertyURI: annotation.get("propertyURI"),
});
this.remove(duplicates);
}

this.add(annotation);
} catch (e) {
console.error(
"Could not replace the EMLAnnotation in the collection: ",
e,
);
addCanonicalDatasetAnnotation(sourceId) {
if (!sourceId) return null;
// TODO: Check that sourceId is a valid DOI or URL

// TODO: Check that there is not already a canonical dataset annotation
// before adding a new one, since there should only be one.
return this.add([
{
propertyLabel: "derivedFrom",
propertyURI: PROV_WAS_DERIVED_FROM,
valueLabel: sourceId,
valueURI: sourceId,
},
{
propertyLabel: "sameAs",
propertyURI: SCHEMA_ORG_SAME_AS,
valueLabel: sourceId,
valueURI: sourceId,
},
]);
},

/**
* 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")) {
// TODO? Check that the URI is a valid DOI or URL
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, so return it
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 canonical dataset annotations from this collection.
* @returns {EMLAnnotation[]} The canonical dataset annotations that were
* removed.
* @since 0.0.0
*/
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;
},
},
);
Expand Down
29 changes: 29 additions & 0 deletions src/js/models/metadata/eml211/EML211.js
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (!this.units.length) this.createUnits();
},
Expand All @@ -160,6 +168,17 @@ define([
);
},

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()
Expand Down Expand Up @@ -734,6 +753,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"];
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (annotations) {
const canonicalDataset = annotations.getCanonicalURI();
if (canonicalDataset) {
modelJSON["canonicalDataset"] = canonicalDataset;
robyngit marked this conversation as resolved.
Show resolved Hide resolved
}
}

return modelJSON;
},

Expand Down
5 changes: 3 additions & 2 deletions src/js/models/metadata/eml211/EMLAnnotation.js
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) {
},

initialize: function (attributes, opions) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <func-names> reported by reviewdog 🐶
Unexpected unnamed method 'initialize'.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.

Suggested change
initialize: function (attributes, opions) {
initialize (attributes, opions) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <no-unused-vars> reported by reviewdog 🐶
'attributes' is defined but never used. Allowed unused args must match /^_/u.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <no-unused-vars> reported by reviewdog 🐶
'opions' is defined but never used. Allowed unused args must match /^_/u.

this.on("change", this.trickleUpChange);
this.stopListening(this, "change", this.trickleUpChange);
this.listenTo(this, "change", this.trickleUpChange);
},

parse: function (attributes, options) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [eslint] <func-names> reported by reviewdog 🐶
Unexpected unnamed method 'parse'.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <no-unused-vars> reported by reviewdog 🐶
'options' is defined but never used. Allowed unused args must match /^_/u.

Expand Down Expand Up @@ -175,7 +176,7 @@ define(["jquery", "underscore", "backbone"], function ($, _, Backbone) {

/* Let the top level package know of attribute changes from this object */
trickleUpChange: function () {
robyngit marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [eslint] <object-shorthand> reported by reviewdog 🐶
Expected method shorthand.

Suggested change
trickleUpChange: function () {
trickleUpChange () {

MetacatUI.rootDataPackage.packageModel.set("changed", true);
MetacatUI.rootDataPackage.packageModel?.set("changed", true);
},
},
);
Expand Down
8 changes: 8 additions & 0 deletions src/js/templates/metadata/metadataOverview.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ <h5>Alternate Identifiers <i class="required-icon hidden" data-category="alterna
list any additional identifiers that can be used to locate or label the dataset here.</p>
<p class="notification" data-category="alternateIdentifier"></p>
</div>

<div class="canonical-id basic-text-container" data-category="canonicalDataset">
<h5>Canonical Dataset <i class="required-icon hidden" data-category="canonicalDataset"></i></h5>
<p class="subtle">If this dataset is essentially a duplicate of a version
stored elsewhere, provide the ID of the original dataset here. This must be a
DOI or URL</p>
<p class="notification" data-category="canonicalDataset"></p>
</div>
26 changes: 21 additions & 5 deletions src/js/views/metadata/EML211View.js
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,13 @@ define([
);
$(overviewEl).find(".altids").append(altIdsEls);

// Canonical Identifier
const canonicalIdEl = this.createBasicTextFields(
"canonicalDataset",
"Add a new canonical identifier",
);
$(overviewEl).find(".canonical-id").append(canonicalIdEl);

//Usage
robyngit marked this conversation as resolved.
Show resolved Hide resolved
//Find the model value that matches a radio button and check it
robyngit marked this conversation as resolved.
Show resolved Hide resolved
// Note the replace() call removing newlines and replacing them with a single space
Expand Down Expand Up @@ -1909,7 +1916,7 @@ define([
.addClass("basic-text");
textRow.append(input.clone().val(value));

if (category != "title")
if (category !== "title" && category !== "canonicalDataset")
textRow.append(
this.createRemoveButton(
null,
Expand All @@ -1922,7 +1929,11 @@ define([
textContainer.append(textRow);

//At the end, append an empty input for the user to add a new one
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (i + 1 == allModelValues.length && category != "title") {
if (
i + 1 == allModelValues.length &&
robyngit marked this conversation as resolved.
Show resolved Hide resolved
category !== "title" &&
category !== "canonicalDataset"
) {
var newRow = $(
robyngit marked this conversation as resolved.
Show resolved Hide resolved
$(document.createElement("div")).addClass("basic-text-row"),
);
robyngit marked this conversation as resolved.
Show resolved Hide resolved
Expand Down Expand Up @@ -2006,7 +2017,12 @@ define([
}

//Add another blank text input
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if ($(e.target).is(".new") && value != "" && category != "title") {
if (
$(e.target).is(".new") &&
value != "" &&
robyngit marked this conversation as resolved.
Show resolved Hide resolved
category != "title" &&
robyngit marked this conversation as resolved.
Show resolved Hide resolved
category !== "canonicalDataset"
) {
$(e.target).removeClass("new");
this.addBasicText(e);
}
Expand Down Expand Up @@ -2036,12 +2052,12 @@ define([
allBasicTexts = $(
".basic-text.new[data-category='" + category + "']",
robyngit marked this conversation as resolved.
Show resolved Hide resolved
);

//Only show one new row at a time
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (allBasicTexts.length == 1 && !allBasicTexts.val()) return;
robyngit marked this conversation as resolved.
Show resolved Hide resolved
else if (allBasicTexts.length > 1) return;
//We are only supporting one title right now
robyngit marked this conversation as resolved.
Show resolved Hide resolved
else if (category == "title") return;
else if (category === "title" || category === "canonicalDataset")
return;
robyngit marked this conversation as resolved.
Show resolved Hide resolved

//Add another blank text input
robyngit marked this conversation as resolved.
Show resolved Hide resolved
var newRow = $(document.createElement("div")).addClass(
Expand Down
Loading
Loading