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

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from

Conversation

robyngit
Copy link
Member

@robyngit robyngit commented Oct 15, 2024

Add an input in the Overview page of the EML Editor View that allows users to identify the authoritative version of the dataset:

image

github-actions[bot]

This comment was marked as resolved.

@robyngit
Copy link
Member Author

robyngit commented Oct 15, 2024

Left to do:

  • Validate the URI value and show validation errors
  • Update the metadataOverview template that is replicated in other themes

@robyngit robyngit linked an issue Oct 17, 2024 that may be closed by this pull request
- Add methods to add, remove, update, and find sameAs and derivedFrom annotations representing canonical datasets

Issue #2542
- Add logic to identify the canonical dataset while parsing EML
- Update the Annotations collection with the value in the canonical dataset input changes

Issue #2542
@robyngit robyngit force-pushed the feature-2542-add-source-dataset branch from 43c3062 to 4df698d Compare October 17, 2024 19:20
Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

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

Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit

eslint

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

addBasicText: function (e) {
var category = $(e.target).attr("data-category"),
allBasicTexts = $(
".basic-text.new[data-category='" + category + "']",
);
//Only show one new row at a time
if (allBasicTexts.length == 1 && !allBasicTexts.val()) return;
else if (allBasicTexts.length > 1) return;
//We are only supporting one title right now
else if (category === "title" || category === "canonicalDataset")
return;
//Add another blank text input
var newRow = $(document.createElement("div")).addClass(
"basic-text-row",
);
newRow.append(
$(document.createElement("input"))
.attr("type", "text")
.attr("data-category", category)
.attr("placeholder", $(e.target).attr("placeholder"))
.addClass("new basic-text"),
);
$(e.target).parent().after(newRow);
$(e.target).after(
this.createRemoveButton(
null,
category,
".basic-text-row",
"div.text-container",
),
);
},


🚫 [eslint] <one-var> reported by reviewdog 🐶
Split 'var' declarations into multiple statements.

var category = $(e.target).attr("data-category"),
allBasicTexts = $(
".basic-text.new[data-category='" + category + "']",
);


🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.

var category = $(e.target).attr("data-category"),
allBasicTexts = $(
".basic-text.new[data-category='" + category + "']",
);


🚫 [eslint] <vars-on-top> reported by reviewdog 🐶
All 'var' declarations must be at the top of the function scope.

var newRow = $(document.createElement("div")).addClass(
"basic-text-row",
);


🚫 [eslint] <no-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.

var newRow = $(document.createElement("div")).addClass(
"basic-text-row",
);

- Handle canonical dataset annotation errors specially so that they can be displayed in the new input field.
- Improve annotations validation to remove empty annotations and duplicates

Issue #2542
@robyngit robyngit force-pushed the feature-2542-add-source-dataset branch from 99cf131 to 877beac Compare November 4, 2024 15:52
github-actions[bot]

This comment was marked as duplicate.

- Fix the findDuplicates method to return the correct result

Issue #2542
@robyngit robyngit marked this pull request as ready for review November 4, 2024 19:20
);
if (canonicalError) {
errors.canonicalDataset = canonicalError.message;
}
}

//Check the required fields for this MetacatUI configuration
Copy link
Contributor

Choose a reason for hiding this comment

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

🚫 [eslint] <spaced-comment> reported by reviewdog 🐶
Expected exception block, space or tab after '//' in comment.

Suggested change
//Check the required fields for this MetacatUI configuration
// Check the required fields for this MetacatUI configuration

Copy link
Contributor

Choose a reason for hiding this comment

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

🚫 [eslint] <prefer-arrow-callback> reported by reviewdog 🐶
Unexpected function expression.

], function (
$,
_,
Backbone,
uuid,
Units,
ScienceMetadata,
DataONEObject,
EMLGeoCoverage,
EMLKeywordSet,
EMLTaxonCoverage,
EMLTemporalCoverage,
EMLDistribution,
EMLEntity,
EMLDataTable,
EMLOtherEntity,
EMLParty,
EMLProject,
EMLText,
EMLMethods,
EMLAnnotations,
EMLAnnotation,
) {
/**
* @class EML211
* @classdesc An EML211 object represents an Ecological Metadata Language
* document, version 2.1.1
* @classcategory Models/Metadata/EML211
* @extends ScienceMetadata
*/
var EML211 = ScienceMetadata.extend(
/** @lends EML211.prototype */ {
type: "EML",
defaults: function () {
return _.extend(ScienceMetadata.prototype.defaults(), {
id: "urn:uuid:" + uuid.v4(),
formatId: "https://eml.ecoinformatics.org/eml-2.2.0",
objectXML: null,
isEditable: false,
alternateIdentifier: [],
shortName: null,
title: [],
creator: [], // array of EMLParty objects
metadataProvider: [], // array of EMLParty objects
associatedParty: [], // array of EMLParty objects
contact: [], // array of EMLParty objects
publisher: [], // array of EMLParty objects
pubDate: null,
language: null,
series: null,
abstract: [], //array of EMLText objects
keywordSets: [], //array of EMLKeywordSet objects
additionalInfo: [],
intellectualRights:
"This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.",
distribution: [], // array of EMLDistribution objects
geoCoverage: [], //an array for EMLGeoCoverages
temporalCoverage: [], //an array of EMLTempCoverage models
taxonCoverage: [], //an array of EMLTaxonCoverages
purpose: [],
entities: [], //An array of EMLEntities
pubplace: null,
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: [
"alternateidentifier",
"shortname",
"title",
"creator",
"metadataprovider",
"associatedparty",
"pubdate",
"language",
"series",
"abstract",
"keywordset",
"additionalinfo",
"intellectualrights",
"licensed",
"distribution",
"coverage",
"annotation",
"purpose",
"introduction",
"gettingstarted",
"acknowledgements",
"maintenance",
"contact",
"publisher",
"pubplace",
"methods",
"project",
"datatable",
"spatialraster",
"spatialvector",
"storedprocedure",
"view",
"otherentity",
"referencepublications",
"usagecitations",
"literaturecited",
],
});
},
units: new Units(),
initialize: function (attributes) {
// Call initialize for the super class
ScienceMetadata.prototype.initialize.call(this, attributes);
// EML211-specific init goes here
// this.set("objectXML", this.createXML());
this.parse(this.createXML());
this.on("sync", function () {
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();
},
url: function (options) {
var identifier;
if (options && options.update) {
identifier = this.get("oldPid") || this.get("seriesid");
} else {
identifier = this.get("id") || this.get("seriesid");
}
return (
MetacatUI.appModel.get("objectServiceUrl") +
encodeURIComponent(identifier)
);
},
/**
* 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()
*/
nodeNameMap: function () {
return _.extend(
this.constructor.__super__.nodeNameMap(),
EMLDistribution.prototype.nodeNameMap(),
EMLGeoCoverage.prototype.nodeNameMap(),
EMLKeywordSet.prototype.nodeNameMap(),
EMLParty.prototype.nodeNameMap(),
EMLProject.prototype.nodeNameMap(),
EMLTaxonCoverage.prototype.nodeNameMap(),
EMLTemporalCoverage.prototype.nodeNameMap(),
EMLMethods.prototype.nodeNameMap(),
{
accuracyreport: "accuracyReport",
actionlist: "actionList",
additionalclassifications: "additionalClassifications",
additionalinfo: "additionalInfo",
additionallinks: "additionalLinks",
additionalmetadata: "additionalMetadata",
allowfirst: "allowFirst",
alternateidentifier: "alternateIdentifier",
altitudedatumname: "altitudeDatumName",
altitudedistanceunits: "altitudeDistanceUnits",
altituderesolution: "altitudeResolution",
altitudeencodingmethod: "altitudeEncodingMethod",
altitudesysdef: "altitudeSysDef",
asneeded: "asNeeded",
associatedparty: "associatedParty",
attributeaccuracyexplanation: "attributeAccuracyExplanation",
attributeaccuracyreport: "attributeAccuracyReport",
attributeaccuracyvalue: "attributeAccuracyValue",
attributedefinition: "attributeDefinition",
attributelabel: "attributeLabel",
attributelist: "attributeList",
attributename: "attributeName",
attributeorientation: "attributeOrientation",
attributereference: "attributeReference",
awardnumber: "awardNumber",
awardurl: "awardUrl",
audiovisual: "audioVisual",
authsystem: "authSystem",
banddescription: "bandDescription",
bilinearfit: "bilinearFit",
binaryrasterformat: "binaryRasterFormat",
blockedmembernode: "blockedMemberNode",
booktitle: "bookTitle",
cameracalibrationinformationavailability:
"cameraCalibrationInformationAvailability",
casesensitive: "caseSensitive",
cellgeometry: "cellGeometry",
cellsizexdirection: "cellSizeXDirection",
cellsizeydirection: "cellSizeYDirection",
changehistory: "changeHistory",
changedate: "changeDate",
changescope: "changeScope",
chapternumber: "chapterNumber",
characterencoding: "characterEncoding",
checkcondition: "checkCondition",
checkconstraint: "checkConstraint",
childoccurences: "childOccurences",
citableclassificationsystem: "citableClassificationSystem",
cloudcoverpercentage: "cloudCoverPercentage",
codedefinition: "codeDefinition",
codeexplanation: "codeExplanation",
codesetname: "codesetName",
codeseturl: "codesetURL",
collapsedelimiters: "collapseDelimiters",
communicationtype: "communicationType",
compressiongenerationquality: "compressionGenerationQuality",
compressionmethod: "compressionMethod",
conferencedate: "conferenceDate",
conferencelocation: "conferenceLocation",
conferencename: "conferenceName",
conferenceproceedings: "conferenceProceedings",
constraintdescription: "constraintDescription",
constraintname: "constraintName",
constanttosi: "constantToSI",
controlpoint: "controlPoint",
cornerpoint: "cornerPoint",
customunit: "customUnit",
dataformat: "dataFormat",
datasetgpolygon: "datasetGPolygon",
datasetgpolygonoutergring: "datasetGPolygonOuterGRing",
datasetgpolygonexclusiongring: "datasetGPolygonExclusionGRing",
datatable: "dataTable",
datatype: "dataType",
datetime: "dateTime",
datetimedomain: "dateTimeDomain",
datetimeprecision: "dateTimePrecision",
defaultvalue: "defaultValue",
definitionattributereference: "definitionAttributeReference",
denomflatratio: "denomFlatRatio",
depthsysdef: "depthSysDef",
depthdatumname: "depthDatumName",
depthdistanceunits: "depthDistanceUnits",
depthencodingmethod: "depthEncodingMethod",
depthresolution: "depthResolution",
descriptorvalue: "descriptorValue",
dictref: "dictRef",
diskusage: "diskUsage",
domainDescription: "domainDescription",
editedbook: "editedBook",
encodingmethod: "encodingMethod",
endcondition: "endCondition",
entitycodelist: "entityCodeList",
entitydescription: "entityDescription",
entityname: "entityName",
entityreference: "entityReference",
entitytype: "entityType",
enumerateddomain: "enumeratedDomain",
errorbasis: "errorBasis",
errorvalues: "errorValues",
externalcodeset: "externalCodeSet",
externallydefinedformat: "externallyDefinedFormat",
fielddelimiter: "fieldDelimiter",
fieldstartcolumn: "fieldStartColumn",
fieldwidth: "fieldWidth",
filmdistortioninformationavailability:
"filmDistortionInformationAvailability",
foreignkey: "foreignKey",
formatname: "formatName",
formatstring: "formatString",
formatversion: "formatVersion",
fractiondigits: "fractionDigits",
fundername: "funderName",
funderidentifier: "funderIdentifier",
gettingstarted: "gettingStarted",
gring: "gRing",
gringpoint: "gRingPoint",
gringlatitude: "gRingLatitude",
gringlongitude: "gRingLongitude",
geogcoordsys: "geogCoordSys",
geometricobjectcount: "geometricObjectCount",
georeferenceinfo: "georeferenceInfo",
highwavelength: "highWavelength",
horizontalaccuracy: "horizontalAccuracy",
horizcoordsysdef: "horizCoordSysDef",
horizcoordsysname: "horizCoordSysName",
identifiername: "identifierName",
illuminationazimuthangle: "illuminationAzimuthAngle",
illuminationelevationangle: "illuminationElevationAngle",
imagingcondition: "imagingCondition",
imagequalitycode: "imageQualityCode",
imageorientationangle: "imageOrientationAngle",
intellectualrights: "intellectualRights",
imagedescription: "imageDescription",
isbn: "ISBN",
issn: "ISSN",
joincondition: "joinCondition",
keywordtype: "keywordType",
languagevalue: "LanguageValue",
languagecodestandard: "LanguageCodeStandard",
lensdistortioninformationavailability:
"lensDistortionInformationAvailability",
licensename: "licenseName",
licenseurl: "licenseURL",
linenumber: "lineNumber",
literalcharacter: "literalCharacter",
literallayout: "literalLayout",
literaturecited: "literatureCited",
lowwavelength: "lowWaveLength",
machineprocessor: "machineProcessor",
maintenanceupdatefrequency: "maintenanceUpdateFrequency",
matrixtype: "matrixType",
maxexclusive: "maxExclusive",
maxinclusive: "maxInclusive",
maxlength: "maxLength",
maxrecordlength: "maxRecordLength",
maxvalues: "maxValues",
measurementscale: "measurementScale",
metadatalist: "metadataList",
methodstep: "methodStep",
minexclusive: "minExclusive",
mininclusive: "minInclusive",
minlength: "minLength",
minvalues: "minValues",
missingvaluecode: "missingValueCode",
moduledocs: "moduleDocs",
modulename: "moduleName",
moduledescription: "moduleDescription",
multiband: "multiBand",
multipliertosi: "multiplierToSI",
nonnumericdomain: "nonNumericDomain",
notnullconstraint: "notNullConstraint",
notplanned: "notPlanned",
numberofbands: "numberOfBands",
numbertype: "numberType",
numericdomain: "numericDomain",
numfooterlines: "numFooterLines",
numheaderlines: "numHeaderLines",
numberofrecords: "numberOfRecords",
numberofvolumes: "numberOfVolumes",
numphysicallinesperrecord: "numPhysicalLinesPerRecord",
objectname: "objectName",
oldvalue: "oldValue",
operatingsystem: "operatingSystem",
orderattributereference: "orderAttributeReference",
originalpublication: "originalPublication",
otherentity: "otherEntity",
othermaintenanceperiod: "otherMaintenancePeriod",
parameterdefinition: "parameterDefinition",
packageid: "packageId",
pagerange: "pageRange",
parentoccurences: "parentOccurences",
parentsi: "parentSI",
peakresponse: "peakResponse",
personalcommunication: "personalCommunication",
physicallinedelimiter: "physicalLineDelimiter",
pointinpixel: "pointInPixel",
preferredmembernode: "preferredMemberNode",
preprocessingtypecode: "preProcessingTypeCode",
primarykey: "primaryKey",
primemeridian: "primeMeridian",
proceduralstep: "proceduralStep",
programminglanguage: "programmingLanguage",
projcoordsys: "projCoordSys",
projectionlist: "projectionList",
propertyuri: "propertyURI",
pubdate: "pubDate",
pubplace: "pubPlace",
publicationplace: "publicationPlace",
quantitativeaccuracyreport: "quantitativeAccuracyReport",
quantitativeaccuracyvalue: "quantitativeAccuracyValue",
quantitativeaccuracymethod: "quantitativeAccuracyMethod",
quantitativeattributeaccuracyassessment:
"quantitativeAttributeAccuracyAssessment",
querystatement: "queryStatement",
quotecharacter: "quoteCharacter",
radiometricdataavailability: "radiometricDataAvailability",
rasterorigin: "rasterOrigin",
recommendedunits: "recommendedUnits",
recommendedusage: "recommendedUsage",
referencedkey: "referencedKey",
referencetype: "referenceType",
relatedentry: "relatedEntry",
relationshiptype: "relationshipType",
reportnumber: "reportNumber",
reprintedition: "reprintEdition",
researchproject: "researchProject",
researchtopic: "researchTopic",
recorddelimiter: "recordDelimiter",
referencepublication: "referencePublication",
revieweditem: "reviewedItem",
rowcolumnorientation: "rowColumnOrientation",
runtimememoryusage: "runtimeMemoryUsage",
samplingdescription: "samplingDescription",
scalefactor: "scaleFactor",
sequenceidentifier: "sequenceIdentifier",
semiaxismajor: "semiAxisMajor",
shortname: "shortName",
simpledelimited: "simpleDelimited",
spatialraster: "spatialRaster",
spatialreference: "spatialReference",
spatialvector: "spatialVector",
standalone: "standAlone",
standardunit: "standardUnit",
startcondition: "startCondition",
studyareadescription: "studyAreaDescription",
storagetype: "storageType",
studyextent: "studyExtent",
studytype: "studyType",
textdelimited: "textDelimited",
textdomain: "textDomain",
textfixed: "textFixed",
textformat: "textFormat",
topologylevel: "topologyLevel",
tonegradation: "toneGradation",
totaldigits: "totalDigits",
totalfigures: "totalFigures",
totalpages: "totalPages",
totaltables: "totalTables",
triangulationindicator: "triangulationIndicator",
typesystem: "typeSystem",
uniquekey: "uniqueKey",
unittype: "unitType",
unitlist: "unitList",
usagecitation: "usageCitation",
valueuri: "valueURI",
valueattributereference: "valueAttributeReference",
verticalaccuracy: "verticalAccuracy",
vertcoordsys: "vertCoordSys",
virtualmachine: "virtualMachine",
wavelengthunits: "waveLengthUnits",
whitespace: "whiteSpace",
xintercept: "xIntercept",
xcoordinate: "xCoordinate",
"xsi:schemalocation": "xsi:schemaLocation",
xslope: "xSlope",
ycoordinate: "yCoordinate",
yintercept: "yIntercept",
yslope: "ySlope",
},
);
},
/**
* Fetch the EML from the MN object service
* @param {object} [options] - A set of options for this fetch()
* @property {boolean} [options.systemMetadataOnly=false] - If true, only the system metadata will be fetched.
* If false, the system metadata AND EML document will be fetched.
*/
fetch: function (options) {
if (!options) var options = {};
//Add the authorization header and other AJAX settings
_.extend(options, MetacatUI.appUserModel.createAjaxSettings(), {
dataType: "text",
});
// Merge the system metadata into the object first
_.extend(options, { merge: true });
this.fetchSystemMetadata(options);
//If we are retrieving system metadata only, then exit now
if (options.systemMetadataOnly) return;
//Call Backbone.Model.fetch to retrieve the info
return Backbone.Model.prototype.fetch.call(this, options);
},
/*
Deserialize an EML 2.1.1 XML document
*/
parse: function (response) {
// Save a reference to this model for use in setting the
// parentModel inside anonymous functions
var model = this;
//If the response is XML
if (typeof response == "string" && response.indexOf("<") == 0) {
//Look for a system metadata tag and call DataONEObject parse instead
if (response.indexOf("systemMetadata>") > -1)
return DataONEObject.prototype.parse.call(this, response);
response = this.cleanUpXML(response);
response = this.dereference(response);
this.set("objectXML", response);
var emlElement = $($.parseHTML(response)).filter("eml\\:eml");
}
var datasetEl;
if (emlElement[0]) datasetEl = $(emlElement[0]).find("dataset");
if (!datasetEl || !datasetEl.length) return {};
var emlParties = [
"metadataprovider",
"associatedparty",
"creator",
"contact",
"publisher",
],
emlDistribution = ["distribution"],
emlEntities = [
"datatable",
"otherentity",
"spatialvector",
"spatialraster",
"storedprocedure",
"view",
],
emlText = ["abstract", "additionalinfo"],
emlMethods = ["methods"];
var nodes = datasetEl.children(),
modelJSON = {};
for (var i = 0; i < nodes.length; i++) {
var thisNode = nodes[i];
var convertedName =
this.nodeNameMap()[thisNode.localName] || thisNode.localName;
//EML Party modules are stored in EMLParty models
if (_.contains(emlParties, thisNode.localName)) {
if (thisNode.localName == "metadataprovider")
var attributeName = "metadataProvider";
else if (thisNode.localName == "associatedparty")
var attributeName = "associatedParty";
else var attributeName = thisNode.localName;
if (typeof modelJSON[attributeName] == "undefined")
modelJSON[attributeName] = [];
modelJSON[attributeName].push(
new EMLParty({
objectDOM: thisNode,
parentModel: model,
type: attributeName,
}),
);
}
//EML Distribution modules are stored in EMLDistribution models
else if (_.contains(emlDistribution, thisNode.localName)) {
if (typeof modelJSON[thisNode.localName] == "undefined")
modelJSON[thisNode.localName] = [];
modelJSON[thisNode.localName].push(
new EMLDistribution(
{
objectDOM: thisNode,
parentModel: model,
},
{ parse: true },
),
);
}
//The EML Project is stored in the EMLProject model
else if (thisNode.localName == "project") {
modelJSON.project = new EMLProject({
objectDOM: thisNode,
parentModel: model,
});
}
//EML Temporal, Taxonomic, and Geographic Coverage modules are stored in their own models
else if (thisNode.localName == "coverage") {
var temporal = $(thisNode).children("temporalcoverage"),
geo = $(thisNode).children("geographiccoverage"),
taxon = $(thisNode).children("taxonomiccoverage");
if (temporal.length) {
modelJSON.temporalCoverage = [];
_.each(temporal, function (t) {
modelJSON.temporalCoverage.push(
new EMLTemporalCoverage({
objectDOM: t,
parentModel: model,
}),
);
});
}
if (geo.length) {
modelJSON.geoCoverage = [];
_.each(geo, function (g) {
modelJSON.geoCoverage.push(
new EMLGeoCoverage({
objectDOM: g,
parentModel: model,
}),
);
});
}
if (taxon.length) {
modelJSON.taxonCoverage = [];
_.each(taxon, function (t) {
modelJSON.taxonCoverage.push(
new EMLTaxonCoverage({
objectDOM: t,
parentModel: model,
}),
);
});
}
}
//Parse EMLText modules
else if (_.contains(emlText, thisNode.localName)) {
if (typeof modelJSON[convertedName] == "undefined")
modelJSON[convertedName] = [];
modelJSON[convertedName].push(
new EMLText({
objectDOM: thisNode,
parentModel: model,
}),
);
} else if (_.contains(emlMethods, thisNode.localName)) {
if (typeof modelJSON[thisNode.localName] === "undefined")
modelJSON[thisNode.localName] = [];
modelJSON[thisNode.localName] = new EMLMethods({
objectDOM: thisNode,
parentModel: model,
});
}
//Parse keywords
else if (thisNode.localName == "keywordset") {
//Start an array of keyword sets
if (typeof modelJSON["keywordSets"] == "undefined")
modelJSON["keywordSets"] = [];
modelJSON["keywordSets"].push(
new EMLKeywordSet({
objectDOM: thisNode,
parentModel: model,
}),
);
}
//Parse intellectual rights
else if (thisNode.localName == "intellectualrights") {
var value = "";
if ($(thisNode).children("para").length == 1)
value = $(thisNode).children("para").first().text().trim();
else $(thisNode).text().trim();
//If the value is one of our pre-defined options, then add it to the model
//if(_.contains(this.get("intellRightsOptions"), value))
modelJSON["intellectualRights"] = value;
}
//Parse Entities
else if (_.contains(emlEntities, thisNode.localName)) {
//Start an array of Entities
if (typeof modelJSON["entities"] == "undefined")
modelJSON["entities"] = [];
//Create the model
var entityModel;
if (thisNode.localName == "otherentity") {
entityModel = new EMLOtherEntity(
{
objectDOM: thisNode,
parentModel: model,
},
{
parse: true,
},
);
} else if (thisNode.localName == "datatable") {
entityModel = new EMLDataTable(
{
objectDOM: thisNode,
parentModel: model,
},
{
parse: true,
},
);
} else {
entityModel = new EMLEntity(
{
objectDOM: thisNode,
parentModel: model,
entityType: "application/octet-stream",
type: thisNode.localName,
},
{
parse: true,
},
);
}
modelJSON["entities"].push(entityModel);
}
//Parse dataset-level annotations
else if (thisNode.localName === "annotation") {
if (!modelJSON["annotations"]) {
modelJSON["annotations"] = new EMLAnnotations();
}
var annotationModel = new EMLAnnotation(
{
objectDOM: thisNode,
},
{ parse: true },
);
modelJSON["annotations"].add(annotationModel);
} else {
//Is this a multi-valued field in EML?
if (Array.isArray(this.get(convertedName))) {
//If we already have a value for this field, then add this value to the array
if (Array.isArray(modelJSON[convertedName]))
modelJSON[convertedName].push(this.toJson(thisNode));
//If it's the first value for this field, then create a new array
else modelJSON[convertedName] = [this.toJson(thisNode)];
} else modelJSON[convertedName] = this.toJson(thisNode);
}
}
// 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;
},
/*
* Retireves the model attributes and serializes into EML XML, to produce the new or modified EML document.
* Returns the EML XML as a string.
*/
serialize: function () {
//Get the EML document
var xmlString = this.get("objectXML"),
html = $.parseHTML(xmlString),
eml = $(html).filter("eml\\:eml"),
datasetNode = $(eml).find("dataset");
//Update the packageId on the eml node with the EML id
$(eml).attr("packageId", this.get("id"));
// Set id attribute on dataset node if needed
if (this.get("xmlID")) {
$(datasetNode).attr("id", this.get("xmlID"));
}
// Set schema version
$(eml).attr(
"xmlns:eml",
MetacatUI.appModel.get("editorSerializationFormat") ||
"https://eml.ecoinformatics.org/eml-2.2.0",
);
// Set formatID
this.set(
"formatId",
MetacatUI.appModel.get("editorSerializationFormat") ||
"https://eml.ecoinformatics.org/eml-2.2.0",
);
// Ensure xsi:schemaLocation has a value for the current format
eml = this.setSchemaLocation(eml);
var nodeNameMap = this.nodeNameMap();
//Serialize the basic text fields
var basicText = ["alternateIdentifier", "title"];
_.each(
basicText,
function (fieldName) {
var basicTextValues = this.get(fieldName);
if (!Array.isArray(basicTextValues))
basicTextValues = [basicTextValues];
// Remove existing nodes
datasetNode.children(fieldName.toLowerCase()).remove();
// Create new nodes
var nodes = _.map(basicTextValues, function (value) {
if (value) {
var node = document.createElement(fieldName.toLowerCase());
$(node).text(value);
return node;
} else {
return "";
}
});
var insertAfter = this.getEMLPosition(eml, fieldName.toLowerCase());
if (insertAfter) {
insertAfter.after(nodes);
} else {
datasetNode.prepend(nodes);
}
},
this,
);
// Serialize pubDate
// This one is special because it has a default behavior, unlike
// the others: When no pubDate is set, it should be set to
// the current year
var pubDate = this.get("pubDate");
datasetNode.find("pubdate").remove();
if (pubDate != null && pubDate.length > 0) {
var pubDateEl = document.createElement("pubdate");
$(pubDateEl).text(pubDate);
this.getEMLPosition(eml, "pubdate").after(pubDateEl);
}
// Serialize the parts of EML that are eml-text modules
var textFields = ["abstract", "additionalInfo"];
_.each(
textFields,
function (field) {
var fieldName = this.nodeNameMap()[field] || field;
// Get the EMLText model
var emlTextModels = Array.isArray(this.get(field))
? this.get(field)
: [this.get(field)];
if (!emlTextModels.length) return;
// Get the node from the EML doc
var nodes = datasetNode.find(fieldName);
// Update the DOMs for each model
_.each(
emlTextModels,
function (thisTextModel, i) {
//Don't serialize falsey values
if (!thisTextModel) return;
var node;
//Get the existing node or create a new one
if (nodes.length < i + 1) {
node = document.createElement(fieldName);
this.getEMLPosition(eml, fieldName).after(node);
} else {
node = nodes[i];
}
$(node).html($(thisTextModel.updateDOM()).html());
},
this,
);
// Remove the extra nodes
this.removeExtraNodes(nodes, emlTextModels);
},
this,
);
//Create a <coverage> XML node if there isn't one
if (datasetNode.children("coverage").length === 0) {
var coverageNode = $(document.createElement("coverage")),
coveragePosition = this.getEMLPosition(eml, "coverage");
if (coveragePosition) coveragePosition.after(coverageNode);
else datasetNode.append(coverageNode);
} else {
var coverageNode = datasetNode.children("coverage").first();
}
//Serialize the geographic coverage
if (
typeof this.get("geoCoverage") !== "undefined" &&
this.get("geoCoverage").length > 0
) {
// Don't serialize if geoCoverage is invalid
var validCoverages = _.filter(
this.get("geoCoverage"),
function (cov) {
return cov.isValid();
},
);
//Get the existing geo coverage nodes from the EML
var existingGeoCov = datasetNode.find("geographiccoverage");
//Update the DOM of each model
_.each(
validCoverages,
function (cov, position) {
//Update the existing node if it exists
if (existingGeoCov.length - 1 >= position) {
$(existingGeoCov[position]).replaceWith(cov.updateDOM());
}
//Or, append new nodes
else {
var insertAfter = existingGeoCov.length
? datasetNode.find("geographiccoverage").last()
: null;
if (insertAfter) insertAfter.after(cov.updateDOM());
else coverageNode.append(cov.updateDOM());
}
},
this,
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(
datasetNode.find("geographiccoverage"),
validCoverages,
);
} else {
//If there are no geographic coverages, remove the nodes
coverageNode.children("geographiccoverage").remove();
}
//Serialize the taxonomic coverage
if (
typeof this.get("taxonCoverage") !== "undefined" &&
this.get("taxonCoverage").length > 0
) {
// Group the taxonomic coverage models into empty and non-empty
var sortedTaxonModels = _.groupBy(
this.get("taxonCoverage"),
function (t) {
if (_.flatten(t.get("taxonomicClassification")).length > 0) {
return "notEmpty";
} else {
return "empty";
}
},
);
//Get the existing taxon coverage nodes from the EML
var existingTaxonCov = coverageNode.children("taxonomiccoverage");
//Iterate over each taxon coverage and update it's DOM
if (
sortedTaxonModels["notEmpty"] &&
sortedTaxonModels["notEmpty"].length > 0
) {
//Update the DOM of each model
_.each(
sortedTaxonModels["notEmpty"],
function (taxonCoverage, position) {
//Update the existing taxonCoverage node if it exists
if (existingTaxonCov.length - 1 >= position) {
$(existingTaxonCov[position]).replaceWith(
taxonCoverage.updateDOM(),
);
}
//Or, append new nodes
else {
coverageNode.append(taxonCoverage.updateDOM());
}
},
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(existingTaxonCov, this.get("taxonCoverage"));
}
//If all the taxon coverages are empty, remove the parent taxonomicCoverage node
else if (
!sortedTaxonModels["notEmpty"] ||
sortedTaxonModels["notEmpty"].length == 0
) {
existingTaxonCov.remove();
}
}
//Serialize the temporal coverage
var existingTemporalCoverages = datasetNode.find("temporalcoverage");
//Update the DOM of each model
_.each(
this.get("temporalCoverage"),
function (temporalCoverage, position) {
//Update the existing temporalCoverage node if it exists
if (existingTemporalCoverages.length - 1 >= position) {
$(existingTemporalCoverages[position]).replaceWith(
temporalCoverage.updateDOM(),
);
}
//Or, append new nodes
else {
coverageNode.append(temporalCoverage.updateDOM());
}
},
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(
existingTemporalCoverages,
this.get("temporalCoverage"),
);
//Remove the temporal coverage if it is empty
if (!coverageNode.children("temporalcoverage").children().length) {
coverageNode.children("temporalcoverage").remove();
}
//Remove the <coverage> node if it's empty
if (coverageNode.children().length == 0) {
coverageNode.remove();
}
// Dataset-level annotations
datasetNode.children("annotation").remove();
if (this.get("annotations")) {
this.get("annotations").each(function (annotation) {
if (annotation.isEmpty()) {
return;
}
var after = this.getEMLPosition(eml, "annotation");
$(after).after(annotation.updateDOM());
}, this);
//Since there is at least one annotation, the dataset node needs to have an id attribute.
datasetNode.attr("id", this.getUniqueEntityId(this));
}
//If there is no creator, create one from the user
if (!this.get("creator").length) {
var party = new EMLParty({ parentModel: this, type: "creator" });
party.createFromUser();
this.set("creator", [party]);
}
//Serialize the creators
this.serializeParties(eml, "creator");
//Serialize the metadata providers
this.serializeParties(eml, "metadataProvider");
//Serialize the associated parties
this.serializeParties(eml, "associatedParty");
//Serialize the contacts
this.serializeParties(eml, "contact");
//Serialize the publishers
this.serializeParties(eml, "publisher");
// Serialize methods
if (this.get("methods")) {
//If the methods model is empty, remove it from the EML
if (this.get("methods").isEmpty())
datasetNode.find("methods").remove();
else {
//Serialize the methods model
var methodsEl = this.get("methods").updateDOM();
//If the methodsEl is an empty string or other falsey value, then remove the methods node
if (!methodsEl || !$(methodsEl).children().length) {
datasetNode.find("methods").remove();
} else {
//Add the <methods> node to the EML
datasetNode.find("methods").detach();
var insertAfter = this.getEMLPosition(eml, "methods");
if (insertAfter) insertAfter.after(methodsEl);
else datasetNode.append(methodsEl);
}
}
}
//If there are no methods, then remove the methods nodes
else {
if (datasetNode.find("methods").length > 0) {
datasetNode.find("methods").remove();
}
}
//Serialize the keywords
this.serializeKeywords(eml, "keywordSets");
//Serialize the intellectual rights
if (this.get("intellectualRights")) {
if (datasetNode.find("intellectualRights").length)
datasetNode
.find("intellectualRights")
.html("<para>" + this.get("intellectualRights") + "</para>");
else {
this.getEMLPosition(eml, "intellectualrights").after(
$(document.createElement("intellectualRights")).html(
"<para>" + this.get("intellectualRights") + "</para>",
),
);
}
}
// Serialize the distribution
const distributions = this.get("distribution");
if (distributions && distributions.length > 0) {
// Remove existing nodes
datasetNode.children("distribution").remove();
// Get the updated DOMs
const distributionDOMs = distributions.map((d) => d.updateDOM());
// Insert the updated DOMs in their correct positions
distributionDOMs.forEach((dom, i) => {
const insertAfter = this.getEMLPosition(eml, "distribution");
if (insertAfter) {
insertAfter.after(dom);
} else {
datasetNode.append(dom);
}
});
}
//Detach the project elements from the DOM
if (datasetNode.find("project").length) {
datasetNode.find("project").detach();
}
//If there is an EMLProject, update its DOM
if (this.get("project")) {
this.getEMLPosition(eml, "project").after(
this.get("project").updateDOM(),
);
}
//Get the existing taxon coverage nodes from the EML
var existingEntities = datasetNode.find(
"otherEntity, dataTable, spatialRaster, spatialVector, storedProcedure, view",
);
//Serialize the entities
_.each(
this.get("entities"),
function (entity, position) {
//Update the existing node if it exists
if (existingEntities.length - 1 >= position) {
//Remove the entity from the EML
$(existingEntities[position]).detach();
//Insert it into the correct position
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after(
entity.updateDOM(),
);
}
//Or, append new nodes
else {
//Inser the entity into the correct position
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after(
entity.updateDOM(),
);
}
},
this,
);
//Remove extra entities that have been removed
var numExtraEntities =
existingEntities.length - this.get("entities").length;
for (
var i = existingEntities.length - numExtraEntities;
i < existingEntities.length;
i++
) {
$(existingEntities)[i].remove();
}
//Do a final check to make sure there are no duplicate ids in the EML
var elementsWithIDs = $(eml).find("[id]"),
//Get an array of all the ids in this EML doc
allIDs = _.map(elementsWithIDs, function (el) {
return $(el).attr("id");
});
//If there is at least one id in the EML...
if (allIDs && allIDs.length) {
//Boil the array down to just the unique values
var uniqueIDs = _.uniq(allIDs);
//If the unique array is shorter than the array of all ids,
// then there is a duplicate somewhere
if (uniqueIDs.length < allIDs.length) {
//For each element in the EML that has an id,
_.each(elementsWithIDs, function (el) {
//Get the id for this element
var id = $(el).attr("id");
//If there is more than one element in the EML with this id,
if ($(eml).find("[id='" + id + "']").length > 1) {
//And if it is not a unit node, which we don't want to change,
if (!$(el).is("unit"))
//Then change the id attribute to a random uuid
$(el).attr("id", "urn-uuid-" + uuid.v4());
}
});
}
}
//Camel-case the XML
var emlString = "";
_.each(
html,
function (rootEMLNode) {
emlString += this.formatXML(rootEMLNode);
},
this,
);
return emlString;
},
/*
* Given an EML DOM and party type, this function updated and/or adds the EMLParties to the EML
*/
serializeParties: function (eml, type) {
//Remove the nodes from the EML for this party type
$(eml).children("dataset").children(type.toLowerCase()).remove();
//Serialize each party of this type
_.each(
this.get(type),
function (party, i) {
//Get the last node of this type to insert after
var insertAfter = $(eml)
.children("dataset")
.children(type.toLowerCase())
.last();
//If there isn't a node found, find the EML position to insert after
if (!insertAfter.length) {
insertAfter = this.getEMLPosition(eml, type);
}
//Update the DOM of the EMLParty
var emlPartyDOM = party.updateDOM();
//Make sure we don't insert empty EMLParty nodes into the EML
if ($(emlPartyDOM).children().length) {
//Insert the party DOM at the insert position
if (insertAfter && insertAfter.length)
insertAfter.after(emlPartyDOM);
//If an insert position still hasn't been found, then just append to the dataset node
else $(eml).find("dataset").append(emlPartyDOM);
}
},
this,
);
//Create a certain parties from the current app user if none is given
if (type == "contact" && !this.get("contact").length) {
//Get the creators
var creators = this.get("creator"),
contacts = [];
_.each(
creators,
function (creator) {
//Clone the creator model and add it to the contacts array
var newModel = new EMLParty({ parentModel: this });
newModel.set(creator.toJSON());
newModel.set("type", type);
contacts.push(newModel);
},
this,
);
this.set(type, contacts);
//Call this function again to serialize the new models
this.serializeParties(eml, type);
}
},
serializeKeywords: function (eml) {
// Remove all existing keywordSets before appending
$(eml).find("dataset").find("keywordset").remove();
if (this.get("keywordSets").length == 0) return;
// Create the new keywordSets nodes
var nodes = _.map(this.get("keywordSets"), function (kwd) {
return kwd.updateDOM();
});
this.getEMLPosition(eml, "keywordset").after(nodes);
},
/*
* Remoes nodes from the EML that do not have an accompanying model
* (Were probably removed from the EML by the user during editing)
*/
removeExtraNodes: function (nodes, models) {
// Remove the extra nodes
var extraNodes = nodes.length - models.length;
if (extraNodes > 0) {
for (var i = models.length; i < nodes.length; i++) {
$(nodes[i]).remove();
}
}
},
/*
* Saves the EML document to the server using the DataONE API
*/
save: function (attributes, options) {
//Validate before we try anything else
if (!this.isValid()) {
this.trigger("invalid");
this.trigger("cancelSave");
return false;
} else {
this.trigger("valid");
}
this.setFileName();
//Set the upload transfer as in progress
this.set("uploadStatus", "p");
//Reset the draftSaved attribute
this.set("draftSaved", false);
//Create the creator from the current user if none is provided
if (!this.get("creator").length) {
var party = new EMLParty({ parentModel: this, type: "creator" });
party.createFromUser();
this.set("creator", [party]);
}
//Create the contact from the current user if none is provided
if (!this.get("contact").length) {
var party = new EMLParty({ parentModel: this, type: "contact" });
party.createFromUser();
this.set("contact", [party]);
}
//If this is an existing object and there is no system metadata, retrieve it
if (!this.isNew() && !this.get("sysMetaXML")) {
var model = this;
//When the system metadata is fetched, try saving again
var fetchOptions = {
success: function (response) {
model.set(DataONEObject.prototype.parse.call(model, response));
model.save(attributes, options);
},
};
//Fetch the system metadata now
this.fetchSystemMetadata(fetchOptions);
return;
}
//Create a FormData object to send data with our XHR
var formData = new FormData();
try {
//Add the identifier to the XHR data
if (this.isNew()) {
formData.append("pid", this.get("id"));
} else {
//Create a new ID
this.updateID();
//Add the ids to the form data
formData.append("newPid", this.get("id"));
formData.append("pid", this.get("oldPid"));
}
//Serialize the EML XML
var xml = this.serialize();
var xmlBlob = new Blob([xml], { type: "application/xml" });
//Get the size of the new EML XML
this.set("size", xmlBlob.size);
//Get the new checksum of the EML XML
var checksum = md5(xml);
this.set("checksum", checksum);
this.set("checksumAlgorithm", "MD5");
//Create the system metadata XML
var sysMetaXML = this.serializeSysMeta();
//Send the system metadata as a Blob
var sysMetaXMLBlob = new Blob([sysMetaXML], {
type: "application/xml",
});
//Add the object XML and System Metadata XML to the form data
//Append the system metadata first, so we can take advantage of Metacat's streaming multipart handler
formData.append("sysmeta", sysMetaXMLBlob, "sysmeta");
formData.append("object", xmlBlob);
} catch (error) {
//Reset the identifier since we didn't actually update the object
this.resetID();
this.set("uploadStatus", "e");
this.trigger("error");
this.trigger("cancelSave");
return false;
}
var model = this;
var saveOptions = options || {};
_.extend(
saveOptions,
{
data: formData,
cache: false,
contentType: false,
dataType: "text",
processData: false,
parse: false,
//Use the URL function to determine the URL
url: this.isNew() ? this.url() : this.url({ update: true }),
xhr: function () {
var xhr = new window.XMLHttpRequest();
//Upload progress
xhr.upload.addEventListener(
"progress",
function (evt) {
if (evt.lengthComputable) {
var percentComplete = (evt.loaded / evt.total) * 100;
model.set("uploadProgress", percentComplete);
}
},
false,
);
return xhr;
},
success: function (model, response, xhr) {
model.set("numSaveAttempts", 0);
model.set("uploadStatus", "c");
model.set("sysMetaXML", model.serializeSysMeta());
model.set("oldPid", null);
model.fetch({ merge: true, systemMetadataOnly: true });
model.trigger("successSaving", model);
},
error: function (model, response, xhr) {
model.set("numSaveAttempts", model.get("numSaveAttempts") + 1);
var numSaveAttempts = model.get("numSaveAttempts");
//Reset the identifier changes
model.resetID();
if (
numSaveAttempts < 3 &&
(response.status == 408 || response.status == 0)
) {
//Try saving again in 10, 40, and 90 seconds
setTimeout(
function () {
model.save.call(model);
},
numSaveAttempts * numSaveAttempts * 10000,
);
} else {
model.set("numSaveAttempts", 0);
//Get the error error information
var errorDOM = $($.parseHTML(response.responseText)),
errorContainer = errorDOM.filter("error"),
msgContainer = errorContainer.length
? errorContainer.find("description")
: errorDOM.not("style, title"),
errorMsg = msgContainer.length
? msgContainer.text()
: errorDOM;
//When there is no network connection (status == 0), there will be no response text
if (!errorMsg || response.status == 408 || response.status == 0)
errorMsg =
"There was a network issue that prevented your metadata from uploading. " +
"Make sure you are connected to a reliable internet connection.";
//Save the error message in the model
model.set("errorMessage", errorMsg);
//Set the model status as e for error
model.set("uploadStatus", "e");
//Save the EML as a plain text file, until drafts are a supported feature
var copy = model.createTextCopy();
//If the EML copy successfully saved, let the user know that there is a copy saved behind the scenes
model.listenToOnce(copy, "successSaving", function () {
model.set("draftSaved", true);
//Trigger the errorSaving event so other parts of the app know that the model failed to save
//And send the error message with it
model.trigger("errorSaving", errorMsg);
});
//If the EML copy fails to save too, then just display the usual error message
model.listenToOnce(copy, "errorSaving", function () {
//Trigger the errorSaving event so other parts of the app know that the model failed to save
//And send the error message with it
model.trigger("errorSaving", errorMsg);
});
//Save the EML plain text copy
copy.save();
// Track the error
MetacatUI.analytics?.trackException(
`EML save error: ${errorMsg}, EML draft: ${copy.get("id")}`,
model.get("id"),
true,
);
}
},
},
MetacatUI.appUserModel.createAjaxSettings(),
);
return Backbone.Model.prototype.save.call(
this,
attributes,
saveOptions,
);
},
/*
* Checks if this EML model has all the required values necessary to save to the server
*/
validate: function () {
let errors = {};
//A title is always required by EML
if (!this.get("title").length || !this.get("title")[0]) {
errors.title = "A title is required";
}
// Validate the publication date
if (this.get("pubDate") != null) {
if (!this.isValidYearDate(this.get("pubDate"))) {
errors["pubDate"] = [
"The value entered for publication date, '" +
this.get("pubDate") +
"' is not a valid value for this field. Enter with a year (e.g. 2017) or a date in the format YYYY-MM-DD.",
];
}
}
// Validate the temporal coverage
errors.temporalCoverage = [];
//If temporal coverage is required and there aren't any, return an error
if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
!this.get("temporalCoverage").length
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is required and they are all empty, return an error
else if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
})
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is not required, validate each one
else if (
this.get("temporalCoverage").length ||
(MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
}))
) {
//Iterate over each temporal coverage and add it's validation errors
_.each(this.get("temporalCoverage"), function (temporalCoverage) {
if (!temporalCoverage.isValid() && !temporalCoverage.isEmpty()) {
errors.temporalCoverage.push(temporalCoverage.validationError);
}
});
}
//Remove the temporalCoverage attribute if no errors were found
if (errors.temporalCoverage.length == 0) {
delete errors.temporalCoverage;
}
//Validate the EMLParty models
var partyTypes = [
"associatedParty",
"contact",
"creator",
"metadataProvider",
"publisher",
];
_.each(
partyTypes,
function (type) {
var people = this.get(type);
_.each(
people,
function (person, i) {
if (!person.isValid()) {
if (!errors[type]) errors[type] = [person.validationError];
else errors[type].push(person.validationError);
}
},
this,
);
},
this,
);
//Validate the EMLGeoCoverage models
_.each(
this.get("geoCoverage"),
function (geoCoverageModel, i) {
if (!geoCoverageModel.isValid()) {
if (!errors.geoCoverage)
errors.geoCoverage = [geoCoverageModel.validationError];
else errors.geoCoverage.push(geoCoverageModel.validationError);
}
},
this,
);
//Validate the EMLTaxonCoverage model
var taxonModel = this.get("taxonCoverage")[0];
if (!taxonModel.isEmpty() && !taxonModel.isValid()) {
errors = _.extend(errors, taxonModel.validationError);
} else if (
taxonModel.isEmpty() &&
this.get("taxonCoverage").length == 1 &&
MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage
) {
taxonModel.isValid();
errors = _.extend(errors, taxonModel.validationError);
}
//Validate each EMLEntity model
_.each(this.get("entities"), function (entityModel) {
if (!entityModel.isValid()) {
if (!errors.entities)
errors.entities = [entityModel.validationError];
else errors.entities.push(entityModel.validationError);
}
});
//Validate the EML Methods
let emlMethods = this.get("methods");
if (emlMethods) {
if (!emlMethods.isValid()) {
errors.methods = emlMethods.validationError;
}
}
// Validate the EMLAnnotation models
const annotations = this.get("annotations");
const annotationErrors = annotations.validate();
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
for ([field, isRequired] of Object.entries(
MetacatUI.appModel.get("emlEditorRequiredFields"),
)) {
//If it's not required, then go to the next field
if (!isRequired) continue;
if (field == "alternateIdentifier") {
if (
!this.get("alternateIdentifier").length ||
_.every(this.get("alternateIdentifier"), function (altId) {
return altId.trim() == "";
})
)
errors.alternateIdentifier =
"At least one alternate identifier is required.";
} else if (field == "generalTaxonomicCoverage") {
if (
!this.get("taxonCoverage").length ||
!this.get("taxonCoverage")[0].get("generalTaxonomicCoverage")
)
errors.generalTaxonomicCoverage =
"Provide a description of the general taxonomic coverage of this data set.";
} else if (field == "geoCoverage") {
if (!this.get("geoCoverage").length)
errors.geoCoverage = "At least one location is required.";
} else if (field == "intellectualRights") {
if (!this.get("intellectualRights"))
errors.intellectualRights =
"Select usage rights for this data set.";
} else if (field == "studyExtentDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("studyExtentDescription")
)
errors.studyExtentDescription =
"Provide a study extent description.";
} else if (field == "samplingDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("samplingDescription")
)
errors.samplingDescription = "Provide a sampling description.";
} else if (field == "temporalCoverage") {
if (!this.get("temporalCoverage").length)
errors.temporalCoverage =
"Provide the date(s) for this data set.";
} else if (field == "taxonCoverage") {
if (!this.get("taxonCoverage").length)
errors.taxonCoverage =
"At least one taxa rank and value is required.";
} else if (field == "keywordSets") {
if (!this.get("keywordSets").length)
errors.keywordSets = "Provide at least one keyword.";
}
//The EMLMethods model will validate itself for required fields, but
// this is a rudimentary check to make sure the EMLMethods model was created
// in the first place
else if (field == "methods") {
if (!this.get("methods"))
errors.methods = "At least one method step is required.";
} else if (field == "funding") {
// Note: Checks for either the funding or award element. award
// element is checked by the project's objectDOM for now until
// EMLProject fully supports the award element
if (
!this.get("project") ||
!(
this.get("project").get("funding").length ||
(this.get("project").get("objectDOM") &&
this.get("project").get("objectDOM").querySelectorAll &&
this.get("project").get("objectDOM").querySelectorAll("award")
.length > 0)
)
)
errors.funding =
"Provide at least one project funding number or name.";
} else if (field == "abstract") {
if (!this.get("abstract").length)
errors["abstract"] = "Provide an abstract.";
} else if (field == "dataSensitivity") {
if (!this.getDataSensitivity()) {
errors["dataSensitivity"] =
"Pick the category that best describes the level of sensitivity or restriction of the data.";
}
}
//If this is an EMLParty type, check that there is a party of this type in the model
else if (
EMLParty.prototype.partyTypes
.map((t) => t.dataCategory)
.includes(field)
) {
//If this is an associatedParty role
if (EMLParty.prototype.defaults().roleOptions?.includes(field)) {
if (
!this.get("associatedParty")
?.map((p) => p.get("roles"))
.flat()
.includes(field)
) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field)?.length) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field) || !this.get(field)?.length) {
errors[field] = "Provide a " + field + ".";
}
}
if (Object.keys(errors).length) return errors;
else {
return;
}
},
/* Returns a boolean for whether the argument 'value' is a valid
value for EML's yearDate type which is used in a few places.
Note that this method considers a zero-length String to be valid
because the EML211.serialize() method will properly handle a null
or zero-length String by serializing out the current year. */
isValidYearDate: function (value) {
return (
value === "" ||
/^\d{4}$/.test(value) ||
/^\d{4}-\d{2}-\d{2}$/.test(value)
);
},
/*
* Sends an AJAX request to fetch the system metadata for this EML 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);
var model = this,
fetchOptions = _.extend(
{
url:
MetacatUI.appModel.get("metaServiceUrl") +
encodeURIComponent(this.get("id")),
dataType: "text",
success: function (response) {
model.set(DataONEObject.prototype.parse.call(model, response));
//Trigger a custom event that the sys meta was updated
model.trigger("sysMetaUpdated");
},
error: function () {
model.trigger("error");
},
},
options,
);
//Add the authorization header and other AJAX settings
_.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings());
$.ajax(fetchOptions);
},
/*
* Returns the nofde in the given EML document that the given node type
* should be inserted after
*
* Returns false if either the node is not found in the and this should
* be handled by the caller.
*/
getEMLPosition: function (eml, nodeName) {
var nodeOrder = this.get("nodeOrder");
var position = _.indexOf(nodeOrder, nodeName.toLowerCase());
if (position == -1) {
return false;
}
// Go through each node in the node list and find the position where this
// node will be inserted after
for (var i = position - 1; i >= 0; i--) {
if ($(eml).find("dataset").children(nodeOrder[i]).length) {
return $(eml).find("dataset").children(nodeOrder[i]).last();
}
}
return false;
},
/*
* Checks if this model has updates that need to be synced with the server.
*/
hasUpdates: function () {
if (this.constructor.__super__.hasUpdates.call(this)) return true;
//If nothing else has been changed, then this object hasn't had any updates
return false;
},
/*
Add an entity into the EML 2.1.1 object
*/
addEntity: function (emlEntity, position) {
//Get the current list of entities
var currentEntities = this.get("entities");
if (typeof position == "undefined" || position == -1)
currentEntities.push(emlEntity);
//Add the entity model to the entity array
else currentEntities.splice(position, 0, emlEntity);
this.trigger("change:entities");
this.trickleUpChange();
return this;
},
/*
Remove an entity from the EML 2.1.1 object
*/
removeEntity: function (emlEntity) {
if (!emlEntity || typeof emlEntity != "object") return;
//Get the current list of entities
var entities = this.get("entities");
entities = _.without(entities, emlEntity);
this.set("entities", entities);
},
/*
* Find the entity model for a given DataONEObject
*/
getEntity: function (dataONEObj) {
//If an EMLEntity model has been found for this object before, then return it
if (dataONEObj.get("metadataEntity")) {
dataONEObj.get("metadataEntity").set("dataONEObject", dataONEObj);
return dataONEObj.get("metadataEntity");
}
var entity = _.find(
this.get("entities"),
function (e) {
//Matches of the checksum or identifier are definite matches
if (e.get("xmlID") == dataONEObj.getXMLSafeID()) return true;
else if (
e.get("physicalMD5Checksum") &&
e.get("physicalMD5Checksum") == dataONEObj.get("checksum") &&
dataONEObj.get("checksumAlgorithm").toUpperCase() == "MD5"
)
return true;
else if (
e.get("downloadID") &&
e.get("downloadID") == dataONEObj.get("id")
)
return true;
// Get the file name from the EML for this entity
var fileNameFromEML =
e.get("physicalObjectName") || e.get("entityName");
// If the EML file name matches the DataONEObject file name
if (
fileNameFromEML &&
dataONEObj.get("fileName") &&
(fileNameFromEML.toLowerCase() ==
dataONEObj.get("fileName").toLowerCase() ||
fileNameFromEML.replace(/ /g, "_").toLowerCase() ==
dataONEObj.get("fileName").toLowerCase())
) {
//Get an array of all the other entities in this EML
var otherEntities = _.without(this.get("entities"), e);
// If this entity name matches the dataone object file name, AND no other dataone object file name
// matches, then we can assume this is the entity element for this file.
var otherMatchingEntity = _.find(
otherEntities,
function (otherE) {
// Get the file name from the EML for the other entities
var otherFileNameFromEML =
otherE.get("physicalObjectName") ||
otherE.get("entityName");
// If the file names match, return true
if (
otherFileNameFromEML == dataONEObj.get("fileName") ||
otherFileNameFromEML.replace(/ /g, "_") ==
dataONEObj.get("fileName")
)
return true;
},
);
// If this entity's file name didn't match any other file names in the EML,
// then this entity is a match for the given dataONEObject
if (!otherMatchingEntity) return true;
}
},
this,
);
//If we found an entity, give it an ID and return it
if (entity) {
//If this entity has been matched to another DataONEObject already, then don't match it again
if (entity.get("dataONEObject") == dataONEObj) {
return entity;
}
//If this entity has been matched to a different DataONEObject already, then don't match it again.
//i.e. We will not override existing entity<->DataONEObject pairings
else if (entity.get("dataONEObject")) {
return;
} else {
entity.set("dataONEObject", dataONEObj);
}
//Create an XML-safe ID and set it on the Entity model
var entityID = this.getUniqueEntityId(dataONEObj);
entity.set("xmlID", entityID);
//Save a reference to this entity so we don't have to refind it later
dataONEObj.set("metadataEntity", entity);
return entity;
}
//See if one data object is of this type in the package
var matchingTypes = _.filter(this.get("entities"), function (e) {
return (
e.get("formatName") ==
(dataONEObj.get("formatId") || dataONEObj.get("mediaType"))
);
});
if (matchingTypes.length == 1) {
//Create an XML-safe ID and set it on the Entity model
matchingTypes[0].set("xmlID", dataONEObj.getXMLSafeID());
return matchingTypes[0];
}
//If this EML is in a DataPackage with only one other DataONEObject,
// and there is only one entity in the EML, then we can assume they are the same entity
if (this.get("entities").length == 1) {
if (
this.get("collections")[0] &&
this.get("collections")[0].type == "DataPackage" &&
this.get("collections")[0].length == 2 &&
_.contains(this.get("collections")[0].models, dataONEObj)
) {
return this.get("entities")[0];
}
}
return false;
},
createEntity: function (dataONEObject) {
// Add or append an entity to the parent's entity list
var entityModel = new EMLOtherEntity({
entityName: dataONEObject.get("fileName"),
entityType:
dataONEObject.get("formatId") ||
dataONEObject.get("mediaType") ||
"application/octet-stream",
dataONEObject: dataONEObject,
parentModel: this,
xmlID: dataONEObject.getXMLSafeID(),
});
this.addEntity(entityModel);
//If this DataONEObject fails to upload, remove the EML entity
this.listenTo(dataONEObject, "errorSaving", function () {
this.removeEntity(dataONEObject.get("metadataEntity"));
//Listen for a successful save so the entity can be added back
this.listenToOnce(dataONEObject, "successSaving", function () {
this.addEntity(dataONEObject.get("metadataEntity"));
});
});
},
/*
* Creates an XML-safe identifier that is unique to this EML document,
* based on the given DataONEObject model. It is intended for EML entity nodes in particular.
*
* @param {DataONEObject} - a DataONEObject model that this EML documents
* @return {string} - an identifier string unique to this EML document
*/
getUniqueEntityId: function (dataONEObject) {
var uniqueId = "";
uniqueId = dataONEObject.getXMLSafeID();
//Get the EML string, if there is one, to check if this id already exists
var emlString = this.get("objectXML");
//If this id already exists in the EML...
if (emlString && emlString.indexOf(' id="' + uniqueId + '"')) {
//Create a random uuid to use instead
uniqueId = "urn-uuid-" + uuid.v4();
}
return uniqueId;
},
/*
* removeParty - removes the given EMLParty model from this EML211 model's attributes
*/
removeParty: function (partyModel) {
//The list of attributes this EMLParty might be stored in
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (_.contains(this.get(attr), partyModel)) {
this.set(attr, _.without(this.get(attr), partyModel));
}
},
this,
);
},
/**
* Attempt to move a party one index forward within its sibling models
*
* @param {EMLParty} partyModel: The EMLParty model we're moving
*/
movePartyUp: function (partyModel) {
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (!_.contains(this.get(attr), partyModel)) {
return;
}
// Make a clone because we're going to use splice
var models = _.clone(this.get(attr));
// Find the index of the model we're moving
var index = _.findIndex(models, function (m) {
return m === partyModel;
});
if (index === 0) {
// Already first
return;
}
if (index === -1) {
// Couldn't find the model
return;
}
// Do the move using splice and update the model
models.splice(index - 1, 0, models.splice(index, 1)[0]);
this.set(attr, models);
this.trigger("change:" + attr);
},
this,
);
},
/**
* Attempt to move a party one index forward within its sibling models
*
* @param {EMLParty} partyModel: The EMLParty model we're moving
*/
movePartyDown: function (partyModel) {
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (!_.contains(this.get(attr), partyModel)) {
return;
}
// Make a clone because we're going to use splice
var models = _.clone(this.get(attr));
// Find the index of the model we're moving
var index = _.findIndex(models, function (m) {
return m === partyModel;
});
if (index === -1) {
// Couldn't find the model
return;
}
// Figure out where to put the new model
// Leave it in the same place if the next index doesn't exist
// Move one forward if it does
var newIndex = models.length <= index + 1 ? index : index + 1;
// Do the move using splice and update the model
models.splice(newIndex, 0, models.splice(index, 1)[0]);
this.set(attr, models);
this.trigger("change:" + attr);
},
this,
);
},
/*
* Adds the given EMLParty model to this EML211 model in the
* appropriate role array in the given position
*
* @param {EMLParty} - The EMLParty model to add
* @param {number} - The position in the role array in which to insert this EMLParty
* @return {boolean} - Returns true if the EMLParty was successfully added, false if it was cancelled
*/
addParty: function (partyModel, position) {
//If the EMLParty model is empty, don't add it to the EML211 model
if (partyModel.isEmpty()) return false;
//Get the role of this EMLParty
var role = partyModel.get("type") || "associatedParty";
//If this model already contains this EMLParty, then exit
if (_.contains(this.get(role), partyModel)) return false;
if (typeof position == "undefined") {
this.get(role).push(partyModel);
} else {
this.get(role).splice(position, 0, partyModel);
}
this.trigger("change:" + role);
return true;
},
/**
* getPartiesByType - Gets an array of EMLParty members that have a particular party type or role.
* @param {string} partyType - A string that represents either the role or the party type. For example, "contact", "creator", "principalInvestigator", etc.
* @since 2.15.0
*/
getPartiesByType: function (partyType) {
try {
if (!partyType) {
return false;
}
var associatedPartyTypes = new EMLParty().get("roleOptions"),
isAssociatedParty = associatedPartyTypes.includes(partyType),
parties = [];
// For "contact", "creator", "metadataProvider", "publisher", each party type has it's own
// array in the EML model
if (!isAssociatedParty) {
parties = this.get(partyType);
// For "custodianSteward", "principalInvestigator", "collaboratingPrincipalInvestigator", etc.,
// party members are listed in the EML model's associated parties array. Each associated party's
// party type is indicated in the role attribute.
} else {
parties = _.filter(
this.get("associatedParty"),
function (associatedParty) {
return associatedParty.get("roles").includes(partyType);
},
);
}
return parties;
} catch (error) {
console.log(
"Error trying to find a list of party members in an EML model by type. Error details: " +
error,
);
}
},
createUnits: function () {
this.units.fetch();
},
/* Initialize the object XML for brand spankin' new EML objects */
createXML: function () {
let emlSystem = MetacatUI.appModel.get("emlSystem");
emlSystem =
!emlSystem || typeof emlSystem != "string" ? "knb" : emlSystem;
var xml =
'<eml:eml xmlns:eml="https://eml.ecoinformatics.org/eml-2.2.0"></eml:eml>',
eml = $($.parseHTML(xml));
// Set base attributes
eml.attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
eml.attr("xmlns:stmml", "http://www.xml-cml.org/schema/stmml-1.1");
eml.attr(
"xsi:schemaLocation",
"https://eml.ecoinformatics.org/eml-2.2.0 https://eml.ecoinformatics.org/eml-2.2.0/eml.xsd",
);
eml.attr("packageId", this.get("id"));
eml.attr("system", emlSystem);
// Add the dataset
eml.append(document.createElement("dataset"));
eml.find("dataset").append(document.createElement("title"));
var emlString = $(document.createElement("div"))
.append(eml.clone())
.html();
return emlString;
},
/*
Replace elements named "source" with "sourced" due to limitations
with using $.parseHTML() rather than $.parseXML()
@param xmlString The XML string to make the replacement in
*/
cleanUpXML: function (xmlString) {
xmlString.replace("<source>", "<sourced>");
xmlString.replace("</source>", "</sourced>");
return xmlString;
},
createTextCopy: function () {
var emlDraftText =
"EML draft for " +
this.get("id") +
"(" +
this.get("title") +
") by " +
MetacatUI.appUserModel.get("firstName") +
" " +
MetacatUI.appUserModel.get("lastName");
if (this.get("uploadStatus") == "e" && this.get("errorMessage")) {
emlDraftText +=
". This EML had the following save error: `" +
this.get("errorMessage") +
"` ";
} else {
emlDraftText += ": ";
}
emlDraftText += this.serialize();
var plainTextEML = new DataONEObject({
formatId: "text/plain",
fileName:
"eml_draft_" +
(MetacatUI.appUserModel.get("lastName") || "") +
".txt",
uploadFile: new Blob([emlDraftText], { type: "plain/text" }),
synced: true,
});
return plainTextEML;
},
/*
* Cleans up the given text so that it is XML-valid by escaping reserved characters, trimming white space, etc.
*
* @param {string} textString - The string to clean up
* @return {string} - The cleaned up string
*/
cleanXMLText: function (textString) {
if (typeof textString != "string") return;
textString = textString.trim();
//Check for XML/HTML elements
_.each(textString.match(/<\s*[^>]*>/g), function (xmlNode) {
//Encode <, >, and </ substrings
var tagName = xmlNode.replace(/>/g, "&gt;");
tagName = tagName.replace(/</g, "&lt;");
//Replace the xmlNode in the full text string
textString = textString.replace(xmlNode, tagName);
});
//Remove Unicode characters that are not valid XML characters
//Create a regular expression that matches any character that is not a valid XML character
// (see https://www.w3.org/TR/xml/#charsets)
var invalidCharsRegEx =
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g;
textString = textString.replace(invalidCharsRegEx, "");
return textString;
},
/*
Dereference "reference" elements and replace them with a cloned copy
of the referenced content
@param xmlString The XML string with reference elements to transform
*/
dereference: function (xmlString) {
var referencesList; // the array of references elements in the document
var referencedID; // The id of the referenced element
var referencesParentEl; // The parent of the given references element
var referencedEl; // The referenced DOM to be copied
var xmlDOM = $.parseXML(xmlString);
referencesList = xmlDOM.getElementsByTagName("references");
if (referencesList.length) {
// Process each references elements
_.each(
referencesList,
function (referencesEl, index, referencesList) {
// Can't rely on the passed referencesEl since the list length changes
// because of the remove() below. Reuse referencesList[0] for every item:
// referencedID = $(referencesEl).text(); // doesn't work
referencesEl = referencesList[0];
referencedID = $(referencesEl).text();
referencesParentEl = $(referencesEl).parent()[0];
if (typeof referencedID !== "undefined" && referencedID != "") {
referencedEl = xmlDOM.getElementById(referencedID);
if (typeof referencedEl != "undefined") {
// Clone the referenced element and replace the references element
var referencedClone = $(referencedEl).clone()[0];
$(referencesParentEl)
.children(referencesEl.localName)
.replaceWith($(referencedClone).children());
//$(referencesParentEl).append($(referencedClone).children());
$(referencesParentEl).attr("id", DataONEObject.generateId());
}
}
},
xmlDOM,
);
}
return new XMLSerializer().serializeToString(xmlDOM);
},
/*
* Uses the EML `title` to set the `fileName` attribute on this model.
*/
setFileName: function () {
var title = "";
// Get the title from the metadata
if (Array.isArray(this.get("title"))) {
title = this.get("title")[0];
} else if (typeof this.get("title") == "string") {
title = this.get("title");
}
//Max title length
var maxLength = 50;
//trim the string to the maximum length
var trimmedTitle = title.trim().substr(0, maxLength);
//re-trim if we are in the middle of a word
if (trimmedTitle.indexOf(" ") > -1) {
trimmedTitle = trimmedTitle.substr(
0,
Math.min(trimmedTitle.length, trimmedTitle.lastIndexOf(" ")),
);
}
//Replace all non alphanumeric characters with underscores
// and make sure there isn't more than one underscore in a row
trimmedTitle = trimmedTitle
.replace(/[^a-zA-Z0-9]/g, "_")
.replace(/_{2,}/g, "_");
//Set the fileName on the model
this.set("fileName", trimmedTitle + ".xml");
},
trickleUpChange: function () {
if (
!MetacatUI.rootDataPackage ||
!MetacatUI.rootDataPackage.packageModel
)
return;
//Mark the package as changed
MetacatUI.rootDataPackage.packageModel.set("changed", true);
},
/**
* Sets the xsi:schemaLocation attribute on the passed-in Element
* depending on the application configuration.
*
* @param {Element} eml: The root eml:eml element to modify
* @return {Element} The element, possibly modified
*/
setSchemaLocation: function (eml) {
if (!MetacatUI || !MetacatUI.appModel) {
return eml;
}
var current = $(eml).attr("xsi:schemaLocation"),
format = MetacatUI.appModel.get("editorSerializationFormat"),
location = MetacatUI.appModel.get("editorSchemaLocation");
// Return now if we can't do anything anyway
if (!format || !location) {
return eml;
}
// Simply add if the attribute isn't present to begin with
if (!current || typeof current !== "string") {
$(eml).attr("xsi:schemaLocation", format + " " + location);
return eml;
}
// Don't append if it's already present
if (current.indexOf(format) >= 0) {
return eml;
}
$(eml).attr("xsi:schemaLocation", current + " " + location);
return eml;
},
createID: function () {
this.set("xmlID", uuid.v4());
},
/**
* Creates and adds an {@link EMLAnnotation} to this EML211 model with the given annotation data in JSON form.
* @param {object} annotationData The attribute data to set on the new {@link EMLAnnotation}. See {@link EMLAnnotation#defaults} for
* details on what attributes can be passed to the EMLAnnotation. In addition, there is an `elementName` property.
* @property {string} [annotationData.elementName] The name of the EML Element that this
annotation should be applied to. e.g. dataset, entity, attribute. Defaults to `dataset`. NOTE: Right now only dataset annotations are supported until
more annotation editing is added to the EML Editor.
* @property {Boolean} [annotationData.allowDuplicates] If false, this annotation will replace all annotations already set with the same propertyURI.
* By default, more than one annotation with a given propertyURI can be added (defaults to true)
*/
addAnnotation: function (annotationData) {
try {
if (!annotationData || typeof annotationData != "object") {
return;
}
//If no element name is provided, default to the dataset element.
let elementName = "";
if (!annotationData.elementName) {
elementName = "dataset";
} else {
elementName = annotationData.elementName;
}
//Remove the elementName property so it isn't set on the EMLAnnotation model later.
delete annotationData.elementName;
//Check if duplicates are allowed
let allowDuplicates = annotationData.allowDuplicates;
delete annotationData.allowDuplicates;
//Create a new EMLAnnotation model
let annotation = new EMLAnnotation(annotationData);
//Update annotations set on the dataset element
if (elementName == "dataset") {
let annotations = this.get("annotations");
//If the current annotations set on the EML model are not in Array form, change it to an array
if (!annotations) {
annotations = new EMLAnnotations();
}
if (allowDuplicates === false) {
//Add the EMLAnnotation to the collection, making sure to remove duplicates first
annotations.replaceDuplicateWith(annotation);
} else {
annotations.add(annotation);
}
//Set the annotations and force the change to be recognized by the model
this.set("annotations", annotations, { silent: true });
this.handleChange(this, { force: true });
} else {
/** @todo Add annotation support for other EML Elements */
}
} catch (e) {
console.error("Could not add Annotation to the EML: ", e);
}
},
/**
* Finds annotations that are of the `data sensitivity` property from the NCEAS SENSO ontology.
* Returns undefined if none are found. This function returns EMLAnnotation models because the data
* sensitivity is stored in the EML Model as EMLAnnotations and added to EML as semantic annotations.
* @returns {EMLAnnotation[]|undefined}
*/
getDataSensitivity: function () {
try {
let annotations = this.get("annotations");
if (annotations) {
let found = annotations.where({
propertyURI: this.get("dataSensitivityPropertyURI"),
});
if (!found || !found.length) {
return;
} else {
return found;
}
} else {
return;
}
} catch (e) {
console.error("Failed to get Data Sensitivity from EML model: ", e);
return;
}
},
},
);
return EML211;
});

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-var> reported by reviewdog 🐶
Unexpected var, use let or const instead.

var EML211 = ScienceMetadata.extend(
/** @lends EML211.prototype */ {
type: "EML",
defaults: function () {
return _.extend(ScienceMetadata.prototype.defaults(), {
id: "urn:uuid:" + uuid.v4(),
formatId: "https://eml.ecoinformatics.org/eml-2.2.0",
objectXML: null,
isEditable: false,
alternateIdentifier: [],
shortName: null,
title: [],
creator: [], // array of EMLParty objects
metadataProvider: [], // array of EMLParty objects
associatedParty: [], // array of EMLParty objects
contact: [], // array of EMLParty objects
publisher: [], // array of EMLParty objects
pubDate: null,
language: null,
series: null,
abstract: [], //array of EMLText objects
keywordSets: [], //array of EMLKeywordSet objects
additionalInfo: [],
intellectualRights:
"This work is dedicated to the public domain under the Creative Commons Universal 1.0 Public Domain Dedication. To view a copy of this dedication, visit https://creativecommons.org/publicdomain/zero/1.0/.",
distribution: [], // array of EMLDistribution objects
geoCoverage: [], //an array for EMLGeoCoverages
temporalCoverage: [], //an array of EMLTempCoverage models
taxonCoverage: [], //an array of EMLTaxonCoverages
purpose: [],
entities: [], //An array of EMLEntities
pubplace: null,
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: [
"alternateidentifier",
"shortname",
"title",
"creator",
"metadataprovider",
"associatedparty",
"pubdate",
"language",
"series",
"abstract",
"keywordset",
"additionalinfo",
"intellectualrights",
"licensed",
"distribution",
"coverage",
"annotation",
"purpose",
"introduction",
"gettingstarted",
"acknowledgements",
"maintenance",
"contact",
"publisher",
"pubplace",
"methods",
"project",
"datatable",
"spatialraster",
"spatialvector",
"storedprocedure",
"view",
"otherentity",
"referencepublications",
"usagecitations",
"literaturecited",
],
});
},
units: new Units(),
initialize: function (attributes) {
// Call initialize for the super class
ScienceMetadata.prototype.initialize.call(this, attributes);
// EML211-specific init goes here
// this.set("objectXML", this.createXML());
this.parse(this.createXML());
this.on("sync", function () {
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();
},
url: function (options) {
var identifier;
if (options && options.update) {
identifier = this.get("oldPid") || this.get("seriesid");
} else {
identifier = this.get("id") || this.get("seriesid");
}
return (
MetacatUI.appModel.get("objectServiceUrl") +
encodeURIComponent(identifier)
);
},
/**
* 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()
*/
nodeNameMap: function () {
return _.extend(
this.constructor.__super__.nodeNameMap(),
EMLDistribution.prototype.nodeNameMap(),
EMLGeoCoverage.prototype.nodeNameMap(),
EMLKeywordSet.prototype.nodeNameMap(),
EMLParty.prototype.nodeNameMap(),
EMLProject.prototype.nodeNameMap(),
EMLTaxonCoverage.prototype.nodeNameMap(),
EMLTemporalCoverage.prototype.nodeNameMap(),
EMLMethods.prototype.nodeNameMap(),
{
accuracyreport: "accuracyReport",
actionlist: "actionList",
additionalclassifications: "additionalClassifications",
additionalinfo: "additionalInfo",
additionallinks: "additionalLinks",
additionalmetadata: "additionalMetadata",
allowfirst: "allowFirst",
alternateidentifier: "alternateIdentifier",
altitudedatumname: "altitudeDatumName",
altitudedistanceunits: "altitudeDistanceUnits",
altituderesolution: "altitudeResolution",
altitudeencodingmethod: "altitudeEncodingMethod",
altitudesysdef: "altitudeSysDef",
asneeded: "asNeeded",
associatedparty: "associatedParty",
attributeaccuracyexplanation: "attributeAccuracyExplanation",
attributeaccuracyreport: "attributeAccuracyReport",
attributeaccuracyvalue: "attributeAccuracyValue",
attributedefinition: "attributeDefinition",
attributelabel: "attributeLabel",
attributelist: "attributeList",
attributename: "attributeName",
attributeorientation: "attributeOrientation",
attributereference: "attributeReference",
awardnumber: "awardNumber",
awardurl: "awardUrl",
audiovisual: "audioVisual",
authsystem: "authSystem",
banddescription: "bandDescription",
bilinearfit: "bilinearFit",
binaryrasterformat: "binaryRasterFormat",
blockedmembernode: "blockedMemberNode",
booktitle: "bookTitle",
cameracalibrationinformationavailability:
"cameraCalibrationInformationAvailability",
casesensitive: "caseSensitive",
cellgeometry: "cellGeometry",
cellsizexdirection: "cellSizeXDirection",
cellsizeydirection: "cellSizeYDirection",
changehistory: "changeHistory",
changedate: "changeDate",
changescope: "changeScope",
chapternumber: "chapterNumber",
characterencoding: "characterEncoding",
checkcondition: "checkCondition",
checkconstraint: "checkConstraint",
childoccurences: "childOccurences",
citableclassificationsystem: "citableClassificationSystem",
cloudcoverpercentage: "cloudCoverPercentage",
codedefinition: "codeDefinition",
codeexplanation: "codeExplanation",
codesetname: "codesetName",
codeseturl: "codesetURL",
collapsedelimiters: "collapseDelimiters",
communicationtype: "communicationType",
compressiongenerationquality: "compressionGenerationQuality",
compressionmethod: "compressionMethod",
conferencedate: "conferenceDate",
conferencelocation: "conferenceLocation",
conferencename: "conferenceName",
conferenceproceedings: "conferenceProceedings",
constraintdescription: "constraintDescription",
constraintname: "constraintName",
constanttosi: "constantToSI",
controlpoint: "controlPoint",
cornerpoint: "cornerPoint",
customunit: "customUnit",
dataformat: "dataFormat",
datasetgpolygon: "datasetGPolygon",
datasetgpolygonoutergring: "datasetGPolygonOuterGRing",
datasetgpolygonexclusiongring: "datasetGPolygonExclusionGRing",
datatable: "dataTable",
datatype: "dataType",
datetime: "dateTime",
datetimedomain: "dateTimeDomain",
datetimeprecision: "dateTimePrecision",
defaultvalue: "defaultValue",
definitionattributereference: "definitionAttributeReference",
denomflatratio: "denomFlatRatio",
depthsysdef: "depthSysDef",
depthdatumname: "depthDatumName",
depthdistanceunits: "depthDistanceUnits",
depthencodingmethod: "depthEncodingMethod",
depthresolution: "depthResolution",
descriptorvalue: "descriptorValue",
dictref: "dictRef",
diskusage: "diskUsage",
domainDescription: "domainDescription",
editedbook: "editedBook",
encodingmethod: "encodingMethod",
endcondition: "endCondition",
entitycodelist: "entityCodeList",
entitydescription: "entityDescription",
entityname: "entityName",
entityreference: "entityReference",
entitytype: "entityType",
enumerateddomain: "enumeratedDomain",
errorbasis: "errorBasis",
errorvalues: "errorValues",
externalcodeset: "externalCodeSet",
externallydefinedformat: "externallyDefinedFormat",
fielddelimiter: "fieldDelimiter",
fieldstartcolumn: "fieldStartColumn",
fieldwidth: "fieldWidth",
filmdistortioninformationavailability:
"filmDistortionInformationAvailability",
foreignkey: "foreignKey",
formatname: "formatName",
formatstring: "formatString",
formatversion: "formatVersion",
fractiondigits: "fractionDigits",
fundername: "funderName",
funderidentifier: "funderIdentifier",
gettingstarted: "gettingStarted",
gring: "gRing",
gringpoint: "gRingPoint",
gringlatitude: "gRingLatitude",
gringlongitude: "gRingLongitude",
geogcoordsys: "geogCoordSys",
geometricobjectcount: "geometricObjectCount",
georeferenceinfo: "georeferenceInfo",
highwavelength: "highWavelength",
horizontalaccuracy: "horizontalAccuracy",
horizcoordsysdef: "horizCoordSysDef",
horizcoordsysname: "horizCoordSysName",
identifiername: "identifierName",
illuminationazimuthangle: "illuminationAzimuthAngle",
illuminationelevationangle: "illuminationElevationAngle",
imagingcondition: "imagingCondition",
imagequalitycode: "imageQualityCode",
imageorientationangle: "imageOrientationAngle",
intellectualrights: "intellectualRights",
imagedescription: "imageDescription",
isbn: "ISBN",
issn: "ISSN",
joincondition: "joinCondition",
keywordtype: "keywordType",
languagevalue: "LanguageValue",
languagecodestandard: "LanguageCodeStandard",
lensdistortioninformationavailability:
"lensDistortionInformationAvailability",
licensename: "licenseName",
licenseurl: "licenseURL",
linenumber: "lineNumber",
literalcharacter: "literalCharacter",
literallayout: "literalLayout",
literaturecited: "literatureCited",
lowwavelength: "lowWaveLength",
machineprocessor: "machineProcessor",
maintenanceupdatefrequency: "maintenanceUpdateFrequency",
matrixtype: "matrixType",
maxexclusive: "maxExclusive",
maxinclusive: "maxInclusive",
maxlength: "maxLength",
maxrecordlength: "maxRecordLength",
maxvalues: "maxValues",
measurementscale: "measurementScale",
metadatalist: "metadataList",
methodstep: "methodStep",
minexclusive: "minExclusive",
mininclusive: "minInclusive",
minlength: "minLength",
minvalues: "minValues",
missingvaluecode: "missingValueCode",
moduledocs: "moduleDocs",
modulename: "moduleName",
moduledescription: "moduleDescription",
multiband: "multiBand",
multipliertosi: "multiplierToSI",
nonnumericdomain: "nonNumericDomain",
notnullconstraint: "notNullConstraint",
notplanned: "notPlanned",
numberofbands: "numberOfBands",
numbertype: "numberType",
numericdomain: "numericDomain",
numfooterlines: "numFooterLines",
numheaderlines: "numHeaderLines",
numberofrecords: "numberOfRecords",
numberofvolumes: "numberOfVolumes",
numphysicallinesperrecord: "numPhysicalLinesPerRecord",
objectname: "objectName",
oldvalue: "oldValue",
operatingsystem: "operatingSystem",
orderattributereference: "orderAttributeReference",
originalpublication: "originalPublication",
otherentity: "otherEntity",
othermaintenanceperiod: "otherMaintenancePeriod",
parameterdefinition: "parameterDefinition",
packageid: "packageId",
pagerange: "pageRange",
parentoccurences: "parentOccurences",
parentsi: "parentSI",
peakresponse: "peakResponse",
personalcommunication: "personalCommunication",
physicallinedelimiter: "physicalLineDelimiter",
pointinpixel: "pointInPixel",
preferredmembernode: "preferredMemberNode",
preprocessingtypecode: "preProcessingTypeCode",
primarykey: "primaryKey",
primemeridian: "primeMeridian",
proceduralstep: "proceduralStep",
programminglanguage: "programmingLanguage",
projcoordsys: "projCoordSys",
projectionlist: "projectionList",
propertyuri: "propertyURI",
pubdate: "pubDate",
pubplace: "pubPlace",
publicationplace: "publicationPlace",
quantitativeaccuracyreport: "quantitativeAccuracyReport",
quantitativeaccuracyvalue: "quantitativeAccuracyValue",
quantitativeaccuracymethod: "quantitativeAccuracyMethod",
quantitativeattributeaccuracyassessment:
"quantitativeAttributeAccuracyAssessment",
querystatement: "queryStatement",
quotecharacter: "quoteCharacter",
radiometricdataavailability: "radiometricDataAvailability",
rasterorigin: "rasterOrigin",
recommendedunits: "recommendedUnits",
recommendedusage: "recommendedUsage",
referencedkey: "referencedKey",
referencetype: "referenceType",
relatedentry: "relatedEntry",
relationshiptype: "relationshipType",
reportnumber: "reportNumber",
reprintedition: "reprintEdition",
researchproject: "researchProject",
researchtopic: "researchTopic",
recorddelimiter: "recordDelimiter",
referencepublication: "referencePublication",
revieweditem: "reviewedItem",
rowcolumnorientation: "rowColumnOrientation",
runtimememoryusage: "runtimeMemoryUsage",
samplingdescription: "samplingDescription",
scalefactor: "scaleFactor",
sequenceidentifier: "sequenceIdentifier",
semiaxismajor: "semiAxisMajor",
shortname: "shortName",
simpledelimited: "simpleDelimited",
spatialraster: "spatialRaster",
spatialreference: "spatialReference",
spatialvector: "spatialVector",
standalone: "standAlone",
standardunit: "standardUnit",
startcondition: "startCondition",
studyareadescription: "studyAreaDescription",
storagetype: "storageType",
studyextent: "studyExtent",
studytype: "studyType",
textdelimited: "textDelimited",
textdomain: "textDomain",
textfixed: "textFixed",
textformat: "textFormat",
topologylevel: "topologyLevel",
tonegradation: "toneGradation",
totaldigits: "totalDigits",
totalfigures: "totalFigures",
totalpages: "totalPages",
totaltables: "totalTables",
triangulationindicator: "triangulationIndicator",
typesystem: "typeSystem",
uniquekey: "uniqueKey",
unittype: "unitType",
unitlist: "unitList",
usagecitation: "usageCitation",
valueuri: "valueURI",
valueattributereference: "valueAttributeReference",
verticalaccuracy: "verticalAccuracy",
vertcoordsys: "vertCoordSys",
virtualmachine: "virtualMachine",
wavelengthunits: "waveLengthUnits",
whitespace: "whiteSpace",
xintercept: "xIntercept",
xcoordinate: "xCoordinate",
"xsi:schemalocation": "xsi:schemaLocation",
xslope: "xSlope",
ycoordinate: "yCoordinate",
yintercept: "yIntercept",
yslope: "ySlope",
},
);
},
/**
* Fetch the EML from the MN object service
* @param {object} [options] - A set of options for this fetch()
* @property {boolean} [options.systemMetadataOnly=false] - If true, only the system metadata will be fetched.
* If false, the system metadata AND EML document will be fetched.
*/
fetch: function (options) {
if (!options) var options = {};
//Add the authorization header and other AJAX settings
_.extend(options, MetacatUI.appUserModel.createAjaxSettings(), {
dataType: "text",
});
// Merge the system metadata into the object first
_.extend(options, { merge: true });
this.fetchSystemMetadata(options);
//If we are retrieving system metadata only, then exit now
if (options.systemMetadataOnly) return;
//Call Backbone.Model.fetch to retrieve the info
return Backbone.Model.prototype.fetch.call(this, options);
},
/*
Deserialize an EML 2.1.1 XML document
*/
parse: function (response) {
// Save a reference to this model for use in setting the
// parentModel inside anonymous functions
var model = this;
//If the response is XML
if (typeof response == "string" && response.indexOf("<") == 0) {
//Look for a system metadata tag and call DataONEObject parse instead
if (response.indexOf("systemMetadata>") > -1)
return DataONEObject.prototype.parse.call(this, response);
response = this.cleanUpXML(response);
response = this.dereference(response);
this.set("objectXML", response);
var emlElement = $($.parseHTML(response)).filter("eml\\:eml");
}
var datasetEl;
if (emlElement[0]) datasetEl = $(emlElement[0]).find("dataset");
if (!datasetEl || !datasetEl.length) return {};
var emlParties = [
"metadataprovider",
"associatedparty",
"creator",
"contact",
"publisher",
],
emlDistribution = ["distribution"],
emlEntities = [
"datatable",
"otherentity",
"spatialvector",
"spatialraster",
"storedprocedure",
"view",
],
emlText = ["abstract", "additionalinfo"],
emlMethods = ["methods"];
var nodes = datasetEl.children(),
modelJSON = {};
for (var i = 0; i < nodes.length; i++) {
var thisNode = nodes[i];
var convertedName =
this.nodeNameMap()[thisNode.localName] || thisNode.localName;
//EML Party modules are stored in EMLParty models
if (_.contains(emlParties, thisNode.localName)) {
if (thisNode.localName == "metadataprovider")
var attributeName = "metadataProvider";
else if (thisNode.localName == "associatedparty")
var attributeName = "associatedParty";
else var attributeName = thisNode.localName;
if (typeof modelJSON[attributeName] == "undefined")
modelJSON[attributeName] = [];
modelJSON[attributeName].push(
new EMLParty({
objectDOM: thisNode,
parentModel: model,
type: attributeName,
}),
);
}
//EML Distribution modules are stored in EMLDistribution models
else if (_.contains(emlDistribution, thisNode.localName)) {
if (typeof modelJSON[thisNode.localName] == "undefined")
modelJSON[thisNode.localName] = [];
modelJSON[thisNode.localName].push(
new EMLDistribution(
{
objectDOM: thisNode,
parentModel: model,
},
{ parse: true },
),
);
}
//The EML Project is stored in the EMLProject model
else if (thisNode.localName == "project") {
modelJSON.project = new EMLProject({
objectDOM: thisNode,
parentModel: model,
});
}
//EML Temporal, Taxonomic, and Geographic Coverage modules are stored in their own models
else if (thisNode.localName == "coverage") {
var temporal = $(thisNode).children("temporalcoverage"),
geo = $(thisNode).children("geographiccoverage"),
taxon = $(thisNode).children("taxonomiccoverage");
if (temporal.length) {
modelJSON.temporalCoverage = [];
_.each(temporal, function (t) {
modelJSON.temporalCoverage.push(
new EMLTemporalCoverage({
objectDOM: t,
parentModel: model,
}),
);
});
}
if (geo.length) {
modelJSON.geoCoverage = [];
_.each(geo, function (g) {
modelJSON.geoCoverage.push(
new EMLGeoCoverage({
objectDOM: g,
parentModel: model,
}),
);
});
}
if (taxon.length) {
modelJSON.taxonCoverage = [];
_.each(taxon, function (t) {
modelJSON.taxonCoverage.push(
new EMLTaxonCoverage({
objectDOM: t,
parentModel: model,
}),
);
});
}
}
//Parse EMLText modules
else if (_.contains(emlText, thisNode.localName)) {
if (typeof modelJSON[convertedName] == "undefined")
modelJSON[convertedName] = [];
modelJSON[convertedName].push(
new EMLText({
objectDOM: thisNode,
parentModel: model,
}),
);
} else if (_.contains(emlMethods, thisNode.localName)) {
if (typeof modelJSON[thisNode.localName] === "undefined")
modelJSON[thisNode.localName] = [];
modelJSON[thisNode.localName] = new EMLMethods({
objectDOM: thisNode,
parentModel: model,
});
}
//Parse keywords
else if (thisNode.localName == "keywordset") {
//Start an array of keyword sets
if (typeof modelJSON["keywordSets"] == "undefined")
modelJSON["keywordSets"] = [];
modelJSON["keywordSets"].push(
new EMLKeywordSet({
objectDOM: thisNode,
parentModel: model,
}),
);
}
//Parse intellectual rights
else if (thisNode.localName == "intellectualrights") {
var value = "";
if ($(thisNode).children("para").length == 1)
value = $(thisNode).children("para").first().text().trim();
else $(thisNode).text().trim();
//If the value is one of our pre-defined options, then add it to the model
//if(_.contains(this.get("intellRightsOptions"), value))
modelJSON["intellectualRights"] = value;
}
//Parse Entities
else if (_.contains(emlEntities, thisNode.localName)) {
//Start an array of Entities
if (typeof modelJSON["entities"] == "undefined")
modelJSON["entities"] = [];
//Create the model
var entityModel;
if (thisNode.localName == "otherentity") {
entityModel = new EMLOtherEntity(
{
objectDOM: thisNode,
parentModel: model,
},
{
parse: true,
},
);
} else if (thisNode.localName == "datatable") {
entityModel = new EMLDataTable(
{
objectDOM: thisNode,
parentModel: model,
},
{
parse: true,
},
);
} else {
entityModel = new EMLEntity(
{
objectDOM: thisNode,
parentModel: model,
entityType: "application/octet-stream",
type: thisNode.localName,
},
{
parse: true,
},
);
}
modelJSON["entities"].push(entityModel);
}
//Parse dataset-level annotations
else if (thisNode.localName === "annotation") {
if (!modelJSON["annotations"]) {
modelJSON["annotations"] = new EMLAnnotations();
}
var annotationModel = new EMLAnnotation(
{
objectDOM: thisNode,
},
{ parse: true },
);
modelJSON["annotations"].add(annotationModel);
} else {
//Is this a multi-valued field in EML?
if (Array.isArray(this.get(convertedName))) {
//If we already have a value for this field, then add this value to the array
if (Array.isArray(modelJSON[convertedName]))
modelJSON[convertedName].push(this.toJson(thisNode));
//If it's the first value for this field, then create a new array
else modelJSON[convertedName] = [this.toJson(thisNode)];
} else modelJSON[convertedName] = this.toJson(thisNode);
}
}
// 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;
},
/*
* Retireves the model attributes and serializes into EML XML, to produce the new or modified EML document.
* Returns the EML XML as a string.
*/
serialize: function () {
//Get the EML document
var xmlString = this.get("objectXML"),
html = $.parseHTML(xmlString),
eml = $(html).filter("eml\\:eml"),
datasetNode = $(eml).find("dataset");
//Update the packageId on the eml node with the EML id
$(eml).attr("packageId", this.get("id"));
// Set id attribute on dataset node if needed
if (this.get("xmlID")) {
$(datasetNode).attr("id", this.get("xmlID"));
}
// Set schema version
$(eml).attr(
"xmlns:eml",
MetacatUI.appModel.get("editorSerializationFormat") ||
"https://eml.ecoinformatics.org/eml-2.2.0",
);
// Set formatID
this.set(
"formatId",
MetacatUI.appModel.get("editorSerializationFormat") ||
"https://eml.ecoinformatics.org/eml-2.2.0",
);
// Ensure xsi:schemaLocation has a value for the current format
eml = this.setSchemaLocation(eml);
var nodeNameMap = this.nodeNameMap();
//Serialize the basic text fields
var basicText = ["alternateIdentifier", "title"];
_.each(
basicText,
function (fieldName) {
var basicTextValues = this.get(fieldName);
if (!Array.isArray(basicTextValues))
basicTextValues = [basicTextValues];
// Remove existing nodes
datasetNode.children(fieldName.toLowerCase()).remove();
// Create new nodes
var nodes = _.map(basicTextValues, function (value) {
if (value) {
var node = document.createElement(fieldName.toLowerCase());
$(node).text(value);
return node;
} else {
return "";
}
});
var insertAfter = this.getEMLPosition(eml, fieldName.toLowerCase());
if (insertAfter) {
insertAfter.after(nodes);
} else {
datasetNode.prepend(nodes);
}
},
this,
);
// Serialize pubDate
// This one is special because it has a default behavior, unlike
// the others: When no pubDate is set, it should be set to
// the current year
var pubDate = this.get("pubDate");
datasetNode.find("pubdate").remove();
if (pubDate != null && pubDate.length > 0) {
var pubDateEl = document.createElement("pubdate");
$(pubDateEl).text(pubDate);
this.getEMLPosition(eml, "pubdate").after(pubDateEl);
}
// Serialize the parts of EML that are eml-text modules
var textFields = ["abstract", "additionalInfo"];
_.each(
textFields,
function (field) {
var fieldName = this.nodeNameMap()[field] || field;
// Get the EMLText model
var emlTextModels = Array.isArray(this.get(field))
? this.get(field)
: [this.get(field)];
if (!emlTextModels.length) return;
// Get the node from the EML doc
var nodes = datasetNode.find(fieldName);
// Update the DOMs for each model
_.each(
emlTextModels,
function (thisTextModel, i) {
//Don't serialize falsey values
if (!thisTextModel) return;
var node;
//Get the existing node or create a new one
if (nodes.length < i + 1) {
node = document.createElement(fieldName);
this.getEMLPosition(eml, fieldName).after(node);
} else {
node = nodes[i];
}
$(node).html($(thisTextModel.updateDOM()).html());
},
this,
);
// Remove the extra nodes
this.removeExtraNodes(nodes, emlTextModels);
},
this,
);
//Create a <coverage> XML node if there isn't one
if (datasetNode.children("coverage").length === 0) {
var coverageNode = $(document.createElement("coverage")),
coveragePosition = this.getEMLPosition(eml, "coverage");
if (coveragePosition) coveragePosition.after(coverageNode);
else datasetNode.append(coverageNode);
} else {
var coverageNode = datasetNode.children("coverage").first();
}
//Serialize the geographic coverage
if (
typeof this.get("geoCoverage") !== "undefined" &&
this.get("geoCoverage").length > 0
) {
// Don't serialize if geoCoverage is invalid
var validCoverages = _.filter(
this.get("geoCoverage"),
function (cov) {
return cov.isValid();
},
);
//Get the existing geo coverage nodes from the EML
var existingGeoCov = datasetNode.find("geographiccoverage");
//Update the DOM of each model
_.each(
validCoverages,
function (cov, position) {
//Update the existing node if it exists
if (existingGeoCov.length - 1 >= position) {
$(existingGeoCov[position]).replaceWith(cov.updateDOM());
}
//Or, append new nodes
else {
var insertAfter = existingGeoCov.length
? datasetNode.find("geographiccoverage").last()
: null;
if (insertAfter) insertAfter.after(cov.updateDOM());
else coverageNode.append(cov.updateDOM());
}
},
this,
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(
datasetNode.find("geographiccoverage"),
validCoverages,
);
} else {
//If there are no geographic coverages, remove the nodes
coverageNode.children("geographiccoverage").remove();
}
//Serialize the taxonomic coverage
if (
typeof this.get("taxonCoverage") !== "undefined" &&
this.get("taxonCoverage").length > 0
) {
// Group the taxonomic coverage models into empty and non-empty
var sortedTaxonModels = _.groupBy(
this.get("taxonCoverage"),
function (t) {
if (_.flatten(t.get("taxonomicClassification")).length > 0) {
return "notEmpty";
} else {
return "empty";
}
},
);
//Get the existing taxon coverage nodes from the EML
var existingTaxonCov = coverageNode.children("taxonomiccoverage");
//Iterate over each taxon coverage and update it's DOM
if (
sortedTaxonModels["notEmpty"] &&
sortedTaxonModels["notEmpty"].length > 0
) {
//Update the DOM of each model
_.each(
sortedTaxonModels["notEmpty"],
function (taxonCoverage, position) {
//Update the existing taxonCoverage node if it exists
if (existingTaxonCov.length - 1 >= position) {
$(existingTaxonCov[position]).replaceWith(
taxonCoverage.updateDOM(),
);
}
//Or, append new nodes
else {
coverageNode.append(taxonCoverage.updateDOM());
}
},
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(existingTaxonCov, this.get("taxonCoverage"));
}
//If all the taxon coverages are empty, remove the parent taxonomicCoverage node
else if (
!sortedTaxonModels["notEmpty"] ||
sortedTaxonModels["notEmpty"].length == 0
) {
existingTaxonCov.remove();
}
}
//Serialize the temporal coverage
var existingTemporalCoverages = datasetNode.find("temporalcoverage");
//Update the DOM of each model
_.each(
this.get("temporalCoverage"),
function (temporalCoverage, position) {
//Update the existing temporalCoverage node if it exists
if (existingTemporalCoverages.length - 1 >= position) {
$(existingTemporalCoverages[position]).replaceWith(
temporalCoverage.updateDOM(),
);
}
//Or, append new nodes
else {
coverageNode.append(temporalCoverage.updateDOM());
}
},
);
//Remove existing taxon coverage nodes that don't have an accompanying model
this.removeExtraNodes(
existingTemporalCoverages,
this.get("temporalCoverage"),
);
//Remove the temporal coverage if it is empty
if (!coverageNode.children("temporalcoverage").children().length) {
coverageNode.children("temporalcoverage").remove();
}
//Remove the <coverage> node if it's empty
if (coverageNode.children().length == 0) {
coverageNode.remove();
}
// Dataset-level annotations
datasetNode.children("annotation").remove();
if (this.get("annotations")) {
this.get("annotations").each(function (annotation) {
if (annotation.isEmpty()) {
return;
}
var after = this.getEMLPosition(eml, "annotation");
$(after).after(annotation.updateDOM());
}, this);
//Since there is at least one annotation, the dataset node needs to have an id attribute.
datasetNode.attr("id", this.getUniqueEntityId(this));
}
//If there is no creator, create one from the user
if (!this.get("creator").length) {
var party = new EMLParty({ parentModel: this, type: "creator" });
party.createFromUser();
this.set("creator", [party]);
}
//Serialize the creators
this.serializeParties(eml, "creator");
//Serialize the metadata providers
this.serializeParties(eml, "metadataProvider");
//Serialize the associated parties
this.serializeParties(eml, "associatedParty");
//Serialize the contacts
this.serializeParties(eml, "contact");
//Serialize the publishers
this.serializeParties(eml, "publisher");
// Serialize methods
if (this.get("methods")) {
//If the methods model is empty, remove it from the EML
if (this.get("methods").isEmpty())
datasetNode.find("methods").remove();
else {
//Serialize the methods model
var methodsEl = this.get("methods").updateDOM();
//If the methodsEl is an empty string or other falsey value, then remove the methods node
if (!methodsEl || !$(methodsEl).children().length) {
datasetNode.find("methods").remove();
} else {
//Add the <methods> node to the EML
datasetNode.find("methods").detach();
var insertAfter = this.getEMLPosition(eml, "methods");
if (insertAfter) insertAfter.after(methodsEl);
else datasetNode.append(methodsEl);
}
}
}
//If there are no methods, then remove the methods nodes
else {
if (datasetNode.find("methods").length > 0) {
datasetNode.find("methods").remove();
}
}
//Serialize the keywords
this.serializeKeywords(eml, "keywordSets");
//Serialize the intellectual rights
if (this.get("intellectualRights")) {
if (datasetNode.find("intellectualRights").length)
datasetNode
.find("intellectualRights")
.html("<para>" + this.get("intellectualRights") + "</para>");
else {
this.getEMLPosition(eml, "intellectualrights").after(
$(document.createElement("intellectualRights")).html(
"<para>" + this.get("intellectualRights") + "</para>",
),
);
}
}
// Serialize the distribution
const distributions = this.get("distribution");
if (distributions && distributions.length > 0) {
// Remove existing nodes
datasetNode.children("distribution").remove();
// Get the updated DOMs
const distributionDOMs = distributions.map((d) => d.updateDOM());
// Insert the updated DOMs in their correct positions
distributionDOMs.forEach((dom, i) => {
const insertAfter = this.getEMLPosition(eml, "distribution");
if (insertAfter) {
insertAfter.after(dom);
} else {
datasetNode.append(dom);
}
});
}
//Detach the project elements from the DOM
if (datasetNode.find("project").length) {
datasetNode.find("project").detach();
}
//If there is an EMLProject, update its DOM
if (this.get("project")) {
this.getEMLPosition(eml, "project").after(
this.get("project").updateDOM(),
);
}
//Get the existing taxon coverage nodes from the EML
var existingEntities = datasetNode.find(
"otherEntity, dataTable, spatialRaster, spatialVector, storedProcedure, view",
);
//Serialize the entities
_.each(
this.get("entities"),
function (entity, position) {
//Update the existing node if it exists
if (existingEntities.length - 1 >= position) {
//Remove the entity from the EML
$(existingEntities[position]).detach();
//Insert it into the correct position
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after(
entity.updateDOM(),
);
}
//Or, append new nodes
else {
//Inser the entity into the correct position
this.getEMLPosition(eml, entity.get("type").toLowerCase()).after(
entity.updateDOM(),
);
}
},
this,
);
//Remove extra entities that have been removed
var numExtraEntities =
existingEntities.length - this.get("entities").length;
for (
var i = existingEntities.length - numExtraEntities;
i < existingEntities.length;
i++
) {
$(existingEntities)[i].remove();
}
//Do a final check to make sure there are no duplicate ids in the EML
var elementsWithIDs = $(eml).find("[id]"),
//Get an array of all the ids in this EML doc
allIDs = _.map(elementsWithIDs, function (el) {
return $(el).attr("id");
});
//If there is at least one id in the EML...
if (allIDs && allIDs.length) {
//Boil the array down to just the unique values
var uniqueIDs = _.uniq(allIDs);
//If the unique array is shorter than the array of all ids,
// then there is a duplicate somewhere
if (uniqueIDs.length < allIDs.length) {
//For each element in the EML that has an id,
_.each(elementsWithIDs, function (el) {
//Get the id for this element
var id = $(el).attr("id");
//If there is more than one element in the EML with this id,
if ($(eml).find("[id='" + id + "']").length > 1) {
//And if it is not a unit node, which we don't want to change,
if (!$(el).is("unit"))
//Then change the id attribute to a random uuid
$(el).attr("id", "urn-uuid-" + uuid.v4());
}
});
}
}
//Camel-case the XML
var emlString = "";
_.each(
html,
function (rootEMLNode) {
emlString += this.formatXML(rootEMLNode);
},
this,
);
return emlString;
},
/*
* Given an EML DOM and party type, this function updated and/or adds the EMLParties to the EML
*/
serializeParties: function (eml, type) {
//Remove the nodes from the EML for this party type
$(eml).children("dataset").children(type.toLowerCase()).remove();
//Serialize each party of this type
_.each(
this.get(type),
function (party, i) {
//Get the last node of this type to insert after
var insertAfter = $(eml)
.children("dataset")
.children(type.toLowerCase())
.last();
//If there isn't a node found, find the EML position to insert after
if (!insertAfter.length) {
insertAfter = this.getEMLPosition(eml, type);
}
//Update the DOM of the EMLParty
var emlPartyDOM = party.updateDOM();
//Make sure we don't insert empty EMLParty nodes into the EML
if ($(emlPartyDOM).children().length) {
//Insert the party DOM at the insert position
if (insertAfter && insertAfter.length)
insertAfter.after(emlPartyDOM);
//If an insert position still hasn't been found, then just append to the dataset node
else $(eml).find("dataset").append(emlPartyDOM);
}
},
this,
);
//Create a certain parties from the current app user if none is given
if (type == "contact" && !this.get("contact").length) {
//Get the creators
var creators = this.get("creator"),
contacts = [];
_.each(
creators,
function (creator) {
//Clone the creator model and add it to the contacts array
var newModel = new EMLParty({ parentModel: this });
newModel.set(creator.toJSON());
newModel.set("type", type);
contacts.push(newModel);
},
this,
);
this.set(type, contacts);
//Call this function again to serialize the new models
this.serializeParties(eml, type);
}
},
serializeKeywords: function (eml) {
// Remove all existing keywordSets before appending
$(eml).find("dataset").find("keywordset").remove();
if (this.get("keywordSets").length == 0) return;
// Create the new keywordSets nodes
var nodes = _.map(this.get("keywordSets"), function (kwd) {
return kwd.updateDOM();
});
this.getEMLPosition(eml, "keywordset").after(nodes);
},
/*
* Remoes nodes from the EML that do not have an accompanying model
* (Were probably removed from the EML by the user during editing)
*/
removeExtraNodes: function (nodes, models) {
// Remove the extra nodes
var extraNodes = nodes.length - models.length;
if (extraNodes > 0) {
for (var i = models.length; i < nodes.length; i++) {
$(nodes[i]).remove();
}
}
},
/*
* Saves the EML document to the server using the DataONE API
*/
save: function (attributes, options) {
//Validate before we try anything else
if (!this.isValid()) {
this.trigger("invalid");
this.trigger("cancelSave");
return false;
} else {
this.trigger("valid");
}
this.setFileName();
//Set the upload transfer as in progress
this.set("uploadStatus", "p");
//Reset the draftSaved attribute
this.set("draftSaved", false);
//Create the creator from the current user if none is provided
if (!this.get("creator").length) {
var party = new EMLParty({ parentModel: this, type: "creator" });
party.createFromUser();
this.set("creator", [party]);
}
//Create the contact from the current user if none is provided
if (!this.get("contact").length) {
var party = new EMLParty({ parentModel: this, type: "contact" });
party.createFromUser();
this.set("contact", [party]);
}
//If this is an existing object and there is no system metadata, retrieve it
if (!this.isNew() && !this.get("sysMetaXML")) {
var model = this;
//When the system metadata is fetched, try saving again
var fetchOptions = {
success: function (response) {
model.set(DataONEObject.prototype.parse.call(model, response));
model.save(attributes, options);
},
};
//Fetch the system metadata now
this.fetchSystemMetadata(fetchOptions);
return;
}
//Create a FormData object to send data with our XHR
var formData = new FormData();
try {
//Add the identifier to the XHR data
if (this.isNew()) {
formData.append("pid", this.get("id"));
} else {
//Create a new ID
this.updateID();
//Add the ids to the form data
formData.append("newPid", this.get("id"));
formData.append("pid", this.get("oldPid"));
}
//Serialize the EML XML
var xml = this.serialize();
var xmlBlob = new Blob([xml], { type: "application/xml" });
//Get the size of the new EML XML
this.set("size", xmlBlob.size);
//Get the new checksum of the EML XML
var checksum = md5(xml);
this.set("checksum", checksum);
this.set("checksumAlgorithm", "MD5");
//Create the system metadata XML
var sysMetaXML = this.serializeSysMeta();
//Send the system metadata as a Blob
var sysMetaXMLBlob = new Blob([sysMetaXML], {
type: "application/xml",
});
//Add the object XML and System Metadata XML to the form data
//Append the system metadata first, so we can take advantage of Metacat's streaming multipart handler
formData.append("sysmeta", sysMetaXMLBlob, "sysmeta");
formData.append("object", xmlBlob);
} catch (error) {
//Reset the identifier since we didn't actually update the object
this.resetID();
this.set("uploadStatus", "e");
this.trigger("error");
this.trigger("cancelSave");
return false;
}
var model = this;
var saveOptions = options || {};
_.extend(
saveOptions,
{
data: formData,
cache: false,
contentType: false,
dataType: "text",
processData: false,
parse: false,
//Use the URL function to determine the URL
url: this.isNew() ? this.url() : this.url({ update: true }),
xhr: function () {
var xhr = new window.XMLHttpRequest();
//Upload progress
xhr.upload.addEventListener(
"progress",
function (evt) {
if (evt.lengthComputable) {
var percentComplete = (evt.loaded / evt.total) * 100;
model.set("uploadProgress", percentComplete);
}
},
false,
);
return xhr;
},
success: function (model, response, xhr) {
model.set("numSaveAttempts", 0);
model.set("uploadStatus", "c");
model.set("sysMetaXML", model.serializeSysMeta());
model.set("oldPid", null);
model.fetch({ merge: true, systemMetadataOnly: true });
model.trigger("successSaving", model);
},
error: function (model, response, xhr) {
model.set("numSaveAttempts", model.get("numSaveAttempts") + 1);
var numSaveAttempts = model.get("numSaveAttempts");
//Reset the identifier changes
model.resetID();
if (
numSaveAttempts < 3 &&
(response.status == 408 || response.status == 0)
) {
//Try saving again in 10, 40, and 90 seconds
setTimeout(
function () {
model.save.call(model);
},
numSaveAttempts * numSaveAttempts * 10000,
);
} else {
model.set("numSaveAttempts", 0);
//Get the error error information
var errorDOM = $($.parseHTML(response.responseText)),
errorContainer = errorDOM.filter("error"),
msgContainer = errorContainer.length
? errorContainer.find("description")
: errorDOM.not("style, title"),
errorMsg = msgContainer.length
? msgContainer.text()
: errorDOM;
//When there is no network connection (status == 0), there will be no response text
if (!errorMsg || response.status == 408 || response.status == 0)
errorMsg =
"There was a network issue that prevented your metadata from uploading. " +
"Make sure you are connected to a reliable internet connection.";
//Save the error message in the model
model.set("errorMessage", errorMsg);
//Set the model status as e for error
model.set("uploadStatus", "e");
//Save the EML as a plain text file, until drafts are a supported feature
var copy = model.createTextCopy();
//If the EML copy successfully saved, let the user know that there is a copy saved behind the scenes
model.listenToOnce(copy, "successSaving", function () {
model.set("draftSaved", true);
//Trigger the errorSaving event so other parts of the app know that the model failed to save
//And send the error message with it
model.trigger("errorSaving", errorMsg);
});
//If the EML copy fails to save too, then just display the usual error message
model.listenToOnce(copy, "errorSaving", function () {
//Trigger the errorSaving event so other parts of the app know that the model failed to save
//And send the error message with it
model.trigger("errorSaving", errorMsg);
});
//Save the EML plain text copy
copy.save();
// Track the error
MetacatUI.analytics?.trackException(
`EML save error: ${errorMsg}, EML draft: ${copy.get("id")}`,
model.get("id"),
true,
);
}
},
},
MetacatUI.appUserModel.createAjaxSettings(),
);
return Backbone.Model.prototype.save.call(
this,
attributes,
saveOptions,
);
},
/*
* Checks if this EML model has all the required values necessary to save to the server
*/
validate: function () {
let errors = {};
//A title is always required by EML
if (!this.get("title").length || !this.get("title")[0]) {
errors.title = "A title is required";
}
// Validate the publication date
if (this.get("pubDate") != null) {
if (!this.isValidYearDate(this.get("pubDate"))) {
errors["pubDate"] = [
"The value entered for publication date, '" +
this.get("pubDate") +
"' is not a valid value for this field. Enter with a year (e.g. 2017) or a date in the format YYYY-MM-DD.",
];
}
}
// Validate the temporal coverage
errors.temporalCoverage = [];
//If temporal coverage is required and there aren't any, return an error
if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
!this.get("temporalCoverage").length
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is required and they are all empty, return an error
else if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
})
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is not required, validate each one
else if (
this.get("temporalCoverage").length ||
(MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
}))
) {
//Iterate over each temporal coverage and add it's validation errors
_.each(this.get("temporalCoverage"), function (temporalCoverage) {
if (!temporalCoverage.isValid() && !temporalCoverage.isEmpty()) {
errors.temporalCoverage.push(temporalCoverage.validationError);
}
});
}
//Remove the temporalCoverage attribute if no errors were found
if (errors.temporalCoverage.length == 0) {
delete errors.temporalCoverage;
}
//Validate the EMLParty models
var partyTypes = [
"associatedParty",
"contact",
"creator",
"metadataProvider",
"publisher",
];
_.each(
partyTypes,
function (type) {
var people = this.get(type);
_.each(
people,
function (person, i) {
if (!person.isValid()) {
if (!errors[type]) errors[type] = [person.validationError];
else errors[type].push(person.validationError);
}
},
this,
);
},
this,
);
//Validate the EMLGeoCoverage models
_.each(
this.get("geoCoverage"),
function (geoCoverageModel, i) {
if (!geoCoverageModel.isValid()) {
if (!errors.geoCoverage)
errors.geoCoverage = [geoCoverageModel.validationError];
else errors.geoCoverage.push(geoCoverageModel.validationError);
}
},
this,
);
//Validate the EMLTaxonCoverage model
var taxonModel = this.get("taxonCoverage")[0];
if (!taxonModel.isEmpty() && !taxonModel.isValid()) {
errors = _.extend(errors, taxonModel.validationError);
} else if (
taxonModel.isEmpty() &&
this.get("taxonCoverage").length == 1 &&
MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage
) {
taxonModel.isValid();
errors = _.extend(errors, taxonModel.validationError);
}
//Validate each EMLEntity model
_.each(this.get("entities"), function (entityModel) {
if (!entityModel.isValid()) {
if (!errors.entities)
errors.entities = [entityModel.validationError];
else errors.entities.push(entityModel.validationError);
}
});
//Validate the EML Methods
let emlMethods = this.get("methods");
if (emlMethods) {
if (!emlMethods.isValid()) {
errors.methods = emlMethods.validationError;
}
}
// Validate the EMLAnnotation models
const annotations = this.get("annotations");
const annotationErrors = annotations.validate();
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
for ([field, isRequired] of Object.entries(
MetacatUI.appModel.get("emlEditorRequiredFields"),
)) {
//If it's not required, then go to the next field
if (!isRequired) continue;
if (field == "alternateIdentifier") {
if (
!this.get("alternateIdentifier").length ||
_.every(this.get("alternateIdentifier"), function (altId) {
return altId.trim() == "";
})
)
errors.alternateIdentifier =
"At least one alternate identifier is required.";
} else if (field == "generalTaxonomicCoverage") {
if (
!this.get("taxonCoverage").length ||
!this.get("taxonCoverage")[0].get("generalTaxonomicCoverage")
)
errors.generalTaxonomicCoverage =
"Provide a description of the general taxonomic coverage of this data set.";
} else if (field == "geoCoverage") {
if (!this.get("geoCoverage").length)
errors.geoCoverage = "At least one location is required.";
} else if (field == "intellectualRights") {
if (!this.get("intellectualRights"))
errors.intellectualRights =
"Select usage rights for this data set.";
} else if (field == "studyExtentDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("studyExtentDescription")
)
errors.studyExtentDescription =
"Provide a study extent description.";
} else if (field == "samplingDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("samplingDescription")
)
errors.samplingDescription = "Provide a sampling description.";
} else if (field == "temporalCoverage") {
if (!this.get("temporalCoverage").length)
errors.temporalCoverage =
"Provide the date(s) for this data set.";
} else if (field == "taxonCoverage") {
if (!this.get("taxonCoverage").length)
errors.taxonCoverage =
"At least one taxa rank and value is required.";
} else if (field == "keywordSets") {
if (!this.get("keywordSets").length)
errors.keywordSets = "Provide at least one keyword.";
}
//The EMLMethods model will validate itself for required fields, but
// this is a rudimentary check to make sure the EMLMethods model was created
// in the first place
else if (field == "methods") {
if (!this.get("methods"))
errors.methods = "At least one method step is required.";
} else if (field == "funding") {
// Note: Checks for either the funding or award element. award
// element is checked by the project's objectDOM for now until
// EMLProject fully supports the award element
if (
!this.get("project") ||
!(
this.get("project").get("funding").length ||
(this.get("project").get("objectDOM") &&
this.get("project").get("objectDOM").querySelectorAll &&
this.get("project").get("objectDOM").querySelectorAll("award")
.length > 0)
)
)
errors.funding =
"Provide at least one project funding number or name.";
} else if (field == "abstract") {
if (!this.get("abstract").length)
errors["abstract"] = "Provide an abstract.";
} else if (field == "dataSensitivity") {
if (!this.getDataSensitivity()) {
errors["dataSensitivity"] =
"Pick the category that best describes the level of sensitivity or restriction of the data.";
}
}
//If this is an EMLParty type, check that there is a party of this type in the model
else if (
EMLParty.prototype.partyTypes
.map((t) => t.dataCategory)
.includes(field)
) {
//If this is an associatedParty role
if (EMLParty.prototype.defaults().roleOptions?.includes(field)) {
if (
!this.get("associatedParty")
?.map((p) => p.get("roles"))
.flat()
.includes(field)
) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field)?.length) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field) || !this.get(field)?.length) {
errors[field] = "Provide a " + field + ".";
}
}
if (Object.keys(errors).length) return errors;
else {
return;
}
},
/* Returns a boolean for whether the argument 'value' is a valid
value for EML's yearDate type which is used in a few places.
Note that this method considers a zero-length String to be valid
because the EML211.serialize() method will properly handle a null
or zero-length String by serializing out the current year. */
isValidYearDate: function (value) {
return (
value === "" ||
/^\d{4}$/.test(value) ||
/^\d{4}-\d{2}-\d{2}$/.test(value)
);
},
/*
* Sends an AJAX request to fetch the system metadata for this EML 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);
var model = this,
fetchOptions = _.extend(
{
url:
MetacatUI.appModel.get("metaServiceUrl") +
encodeURIComponent(this.get("id")),
dataType: "text",
success: function (response) {
model.set(DataONEObject.prototype.parse.call(model, response));
//Trigger a custom event that the sys meta was updated
model.trigger("sysMetaUpdated");
},
error: function () {
model.trigger("error");
},
},
options,
);
//Add the authorization header and other AJAX settings
_.extend(fetchOptions, MetacatUI.appUserModel.createAjaxSettings());
$.ajax(fetchOptions);
},
/*
* Returns the nofde in the given EML document that the given node type
* should be inserted after
*
* Returns false if either the node is not found in the and this should
* be handled by the caller.
*/
getEMLPosition: function (eml, nodeName) {
var nodeOrder = this.get("nodeOrder");
var position = _.indexOf(nodeOrder, nodeName.toLowerCase());
if (position == -1) {
return false;
}
// Go through each node in the node list and find the position where this
// node will be inserted after
for (var i = position - 1; i >= 0; i--) {
if ($(eml).find("dataset").children(nodeOrder[i]).length) {
return $(eml).find("dataset").children(nodeOrder[i]).last();
}
}
return false;
},
/*
* Checks if this model has updates that need to be synced with the server.
*/
hasUpdates: function () {
if (this.constructor.__super__.hasUpdates.call(this)) return true;
//If nothing else has been changed, then this object hasn't had any updates
return false;
},
/*
Add an entity into the EML 2.1.1 object
*/
addEntity: function (emlEntity, position) {
//Get the current list of entities
var currentEntities = this.get("entities");
if (typeof position == "undefined" || position == -1)
currentEntities.push(emlEntity);
//Add the entity model to the entity array
else currentEntities.splice(position, 0, emlEntity);
this.trigger("change:entities");
this.trickleUpChange();
return this;
},
/*
Remove an entity from the EML 2.1.1 object
*/
removeEntity: function (emlEntity) {
if (!emlEntity || typeof emlEntity != "object") return;
//Get the current list of entities
var entities = this.get("entities");
entities = _.without(entities, emlEntity);
this.set("entities", entities);
},
/*
* Find the entity model for a given DataONEObject
*/
getEntity: function (dataONEObj) {
//If an EMLEntity model has been found for this object before, then return it
if (dataONEObj.get("metadataEntity")) {
dataONEObj.get("metadataEntity").set("dataONEObject", dataONEObj);
return dataONEObj.get("metadataEntity");
}
var entity = _.find(
this.get("entities"),
function (e) {
//Matches of the checksum or identifier are definite matches
if (e.get("xmlID") == dataONEObj.getXMLSafeID()) return true;
else if (
e.get("physicalMD5Checksum") &&
e.get("physicalMD5Checksum") == dataONEObj.get("checksum") &&
dataONEObj.get("checksumAlgorithm").toUpperCase() == "MD5"
)
return true;
else if (
e.get("downloadID") &&
e.get("downloadID") == dataONEObj.get("id")
)
return true;
// Get the file name from the EML for this entity
var fileNameFromEML =
e.get("physicalObjectName") || e.get("entityName");
// If the EML file name matches the DataONEObject file name
if (
fileNameFromEML &&
dataONEObj.get("fileName") &&
(fileNameFromEML.toLowerCase() ==
dataONEObj.get("fileName").toLowerCase() ||
fileNameFromEML.replace(/ /g, "_").toLowerCase() ==
dataONEObj.get("fileName").toLowerCase())
) {
//Get an array of all the other entities in this EML
var otherEntities = _.without(this.get("entities"), e);
// If this entity name matches the dataone object file name, AND no other dataone object file name
// matches, then we can assume this is the entity element for this file.
var otherMatchingEntity = _.find(
otherEntities,
function (otherE) {
// Get the file name from the EML for the other entities
var otherFileNameFromEML =
otherE.get("physicalObjectName") ||
otherE.get("entityName");
// If the file names match, return true
if (
otherFileNameFromEML == dataONEObj.get("fileName") ||
otherFileNameFromEML.replace(/ /g, "_") ==
dataONEObj.get("fileName")
)
return true;
},
);
// If this entity's file name didn't match any other file names in the EML,
// then this entity is a match for the given dataONEObject
if (!otherMatchingEntity) return true;
}
},
this,
);
//If we found an entity, give it an ID and return it
if (entity) {
//If this entity has been matched to another DataONEObject already, then don't match it again
if (entity.get("dataONEObject") == dataONEObj) {
return entity;
}
//If this entity has been matched to a different DataONEObject already, then don't match it again.
//i.e. We will not override existing entity<->DataONEObject pairings
else if (entity.get("dataONEObject")) {
return;
} else {
entity.set("dataONEObject", dataONEObj);
}
//Create an XML-safe ID and set it on the Entity model
var entityID = this.getUniqueEntityId(dataONEObj);
entity.set("xmlID", entityID);
//Save a reference to this entity so we don't have to refind it later
dataONEObj.set("metadataEntity", entity);
return entity;
}
//See if one data object is of this type in the package
var matchingTypes = _.filter(this.get("entities"), function (e) {
return (
e.get("formatName") ==
(dataONEObj.get("formatId") || dataONEObj.get("mediaType"))
);
});
if (matchingTypes.length == 1) {
//Create an XML-safe ID and set it on the Entity model
matchingTypes[0].set("xmlID", dataONEObj.getXMLSafeID());
return matchingTypes[0];
}
//If this EML is in a DataPackage with only one other DataONEObject,
// and there is only one entity in the EML, then we can assume they are the same entity
if (this.get("entities").length == 1) {
if (
this.get("collections")[0] &&
this.get("collections")[0].type == "DataPackage" &&
this.get("collections")[0].length == 2 &&
_.contains(this.get("collections")[0].models, dataONEObj)
) {
return this.get("entities")[0];
}
}
return false;
},
createEntity: function (dataONEObject) {
// Add or append an entity to the parent's entity list
var entityModel = new EMLOtherEntity({
entityName: dataONEObject.get("fileName"),
entityType:
dataONEObject.get("formatId") ||
dataONEObject.get("mediaType") ||
"application/octet-stream",
dataONEObject: dataONEObject,
parentModel: this,
xmlID: dataONEObject.getXMLSafeID(),
});
this.addEntity(entityModel);
//If this DataONEObject fails to upload, remove the EML entity
this.listenTo(dataONEObject, "errorSaving", function () {
this.removeEntity(dataONEObject.get("metadataEntity"));
//Listen for a successful save so the entity can be added back
this.listenToOnce(dataONEObject, "successSaving", function () {
this.addEntity(dataONEObject.get("metadataEntity"));
});
});
},
/*
* Creates an XML-safe identifier that is unique to this EML document,
* based on the given DataONEObject model. It is intended for EML entity nodes in particular.
*
* @param {DataONEObject} - a DataONEObject model that this EML documents
* @return {string} - an identifier string unique to this EML document
*/
getUniqueEntityId: function (dataONEObject) {
var uniqueId = "";
uniqueId = dataONEObject.getXMLSafeID();
//Get the EML string, if there is one, to check if this id already exists
var emlString = this.get("objectXML");
//If this id already exists in the EML...
if (emlString && emlString.indexOf(' id="' + uniqueId + '"')) {
//Create a random uuid to use instead
uniqueId = "urn-uuid-" + uuid.v4();
}
return uniqueId;
},
/*
* removeParty - removes the given EMLParty model from this EML211 model's attributes
*/
removeParty: function (partyModel) {
//The list of attributes this EMLParty might be stored in
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (_.contains(this.get(attr), partyModel)) {
this.set(attr, _.without(this.get(attr), partyModel));
}
},
this,
);
},
/**
* Attempt to move a party one index forward within its sibling models
*
* @param {EMLParty} partyModel: The EMLParty model we're moving
*/
movePartyUp: function (partyModel) {
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (!_.contains(this.get(attr), partyModel)) {
return;
}
// Make a clone because we're going to use splice
var models = _.clone(this.get(attr));
// Find the index of the model we're moving
var index = _.findIndex(models, function (m) {
return m === partyModel;
});
if (index === 0) {
// Already first
return;
}
if (index === -1) {
// Couldn't find the model
return;
}
// Do the move using splice and update the model
models.splice(index - 1, 0, models.splice(index, 1)[0]);
this.set(attr, models);
this.trigger("change:" + attr);
},
this,
);
},
/**
* Attempt to move a party one index forward within its sibling models
*
* @param {EMLParty} partyModel: The EMLParty model we're moving
*/
movePartyDown: function (partyModel) {
var possibleAttr = [
"creator",
"contact",
"metadataProvider",
"publisher",
"associatedParty",
];
// Iterate over each possible attribute
_.each(
possibleAttr,
function (attr) {
if (!_.contains(this.get(attr), partyModel)) {
return;
}
// Make a clone because we're going to use splice
var models = _.clone(this.get(attr));
// Find the index of the model we're moving
var index = _.findIndex(models, function (m) {
return m === partyModel;
});
if (index === -1) {
// Couldn't find the model
return;
}
// Figure out where to put the new model
// Leave it in the same place if the next index doesn't exist
// Move one forward if it does
var newIndex = models.length <= index + 1 ? index : index + 1;
// Do the move using splice and update the model
models.splice(newIndex, 0, models.splice(index, 1)[0]);
this.set(attr, models);
this.trigger("change:" + attr);
},
this,
);
},
/*
* Adds the given EMLParty model to this EML211 model in the
* appropriate role array in the given position
*
* @param {EMLParty} - The EMLParty model to add
* @param {number} - The position in the role array in which to insert this EMLParty
* @return {boolean} - Returns true if the EMLParty was successfully added, false if it was cancelled
*/
addParty: function (partyModel, position) {
//If the EMLParty model is empty, don't add it to the EML211 model
if (partyModel.isEmpty()) return false;
//Get the role of this EMLParty
var role = partyModel.get("type") || "associatedParty";
//If this model already contains this EMLParty, then exit
if (_.contains(this.get(role), partyModel)) return false;
if (typeof position == "undefined") {
this.get(role).push(partyModel);
} else {
this.get(role).splice(position, 0, partyModel);
}
this.trigger("change:" + role);
return true;
},
/**
* getPartiesByType - Gets an array of EMLParty members that have a particular party type or role.
* @param {string} partyType - A string that represents either the role or the party type. For example, "contact", "creator", "principalInvestigator", etc.
* @since 2.15.0
*/
getPartiesByType: function (partyType) {
try {
if (!partyType) {
return false;
}
var associatedPartyTypes = new EMLParty().get("roleOptions"),
isAssociatedParty = associatedPartyTypes.includes(partyType),
parties = [];
// For "contact", "creator", "metadataProvider", "publisher", each party type has it's own
// array in the EML model
if (!isAssociatedParty) {
parties = this.get(partyType);
// For "custodianSteward", "principalInvestigator", "collaboratingPrincipalInvestigator", etc.,
// party members are listed in the EML model's associated parties array. Each associated party's
// party type is indicated in the role attribute.
} else {
parties = _.filter(
this.get("associatedParty"),
function (associatedParty) {
return associatedParty.get("roles").includes(partyType);
},
);
}
return parties;
} catch (error) {
console.log(
"Error trying to find a list of party members in an EML model by type. Error details: " +
error,
);
}
},
createUnits: function () {
this.units.fetch();
},
/* Initialize the object XML for brand spankin' new EML objects */
createXML: function () {
let emlSystem = MetacatUI.appModel.get("emlSystem");
emlSystem =
!emlSystem || typeof emlSystem != "string" ? "knb" : emlSystem;
var xml =
'<eml:eml xmlns:eml="https://eml.ecoinformatics.org/eml-2.2.0"></eml:eml>',
eml = $($.parseHTML(xml));
// Set base attributes
eml.attr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
eml.attr("xmlns:stmml", "http://www.xml-cml.org/schema/stmml-1.1");
eml.attr(
"xsi:schemaLocation",
"https://eml.ecoinformatics.org/eml-2.2.0 https://eml.ecoinformatics.org/eml-2.2.0/eml.xsd",
);
eml.attr("packageId", this.get("id"));
eml.attr("system", emlSystem);
// Add the dataset
eml.append(document.createElement("dataset"));
eml.find("dataset").append(document.createElement("title"));
var emlString = $(document.createElement("div"))
.append(eml.clone())
.html();
return emlString;
},
/*
Replace elements named "source" with "sourced" due to limitations
with using $.parseHTML() rather than $.parseXML()
@param xmlString The XML string to make the replacement in
*/
cleanUpXML: function (xmlString) {
xmlString.replace("<source>", "<sourced>");
xmlString.replace("</source>", "</sourced>");
return xmlString;
},
createTextCopy: function () {
var emlDraftText =
"EML draft for " +
this.get("id") +
"(" +
this.get("title") +
") by " +
MetacatUI.appUserModel.get("firstName") +
" " +
MetacatUI.appUserModel.get("lastName");
if (this.get("uploadStatus") == "e" && this.get("errorMessage")) {
emlDraftText +=
". This EML had the following save error: `" +
this.get("errorMessage") +
"` ";
} else {
emlDraftText += ": ";
}
emlDraftText += this.serialize();
var plainTextEML = new DataONEObject({
formatId: "text/plain",
fileName:
"eml_draft_" +
(MetacatUI.appUserModel.get("lastName") || "") +
".txt",
uploadFile: new Blob([emlDraftText], { type: "plain/text" }),
synced: true,
});
return plainTextEML;
},
/*
* Cleans up the given text so that it is XML-valid by escaping reserved characters, trimming white space, etc.
*
* @param {string} textString - The string to clean up
* @return {string} - The cleaned up string
*/
cleanXMLText: function (textString) {
if (typeof textString != "string") return;
textString = textString.trim();
//Check for XML/HTML elements
_.each(textString.match(/<\s*[^>]*>/g), function (xmlNode) {
//Encode <, >, and </ substrings
var tagName = xmlNode.replace(/>/g, "&gt;");
tagName = tagName.replace(/</g, "&lt;");
//Replace the xmlNode in the full text string
textString = textString.replace(xmlNode, tagName);
});
//Remove Unicode characters that are not valid XML characters
//Create a regular expression that matches any character that is not a valid XML character
// (see https://www.w3.org/TR/xml/#charsets)
var invalidCharsRegEx =
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g;
textString = textString.replace(invalidCharsRegEx, "");
return textString;
},
/*
Dereference "reference" elements and replace them with a cloned copy
of the referenced content
@param xmlString The XML string with reference elements to transform
*/
dereference: function (xmlString) {
var referencesList; // the array of references elements in the document
var referencedID; // The id of the referenced element
var referencesParentEl; // The parent of the given references element
var referencedEl; // The referenced DOM to be copied
var xmlDOM = $.parseXML(xmlString);
referencesList = xmlDOM.getElementsByTagName("references");
if (referencesList.length) {
// Process each references elements
_.each(
referencesList,
function (referencesEl, index, referencesList) {
// Can't rely on the passed referencesEl since the list length changes
// because of the remove() below. Reuse referencesList[0] for every item:
// referencedID = $(referencesEl).text(); // doesn't work
referencesEl = referencesList[0];
referencedID = $(referencesEl).text();
referencesParentEl = $(referencesEl).parent()[0];
if (typeof referencedID !== "undefined" && referencedID != "") {
referencedEl = xmlDOM.getElementById(referencedID);
if (typeof referencedEl != "undefined") {
// Clone the referenced element and replace the references element
var referencedClone = $(referencedEl).clone()[0];
$(referencesParentEl)
.children(referencesEl.localName)
.replaceWith($(referencedClone).children());
//$(referencesParentEl).append($(referencedClone).children());
$(referencesParentEl).attr("id", DataONEObject.generateId());
}
}
},
xmlDOM,
);
}
return new XMLSerializer().serializeToString(xmlDOM);
},
/*
* Uses the EML `title` to set the `fileName` attribute on this model.
*/
setFileName: function () {
var title = "";
// Get the title from the metadata
if (Array.isArray(this.get("title"))) {
title = this.get("title")[0];
} else if (typeof this.get("title") == "string") {
title = this.get("title");
}
//Max title length
var maxLength = 50;
//trim the string to the maximum length
var trimmedTitle = title.trim().substr(0, maxLength);
//re-trim if we are in the middle of a word
if (trimmedTitle.indexOf(" ") > -1) {
trimmedTitle = trimmedTitle.substr(
0,
Math.min(trimmedTitle.length, trimmedTitle.lastIndexOf(" ")),
);
}
//Replace all non alphanumeric characters with underscores
// and make sure there isn't more than one underscore in a row
trimmedTitle = trimmedTitle
.replace(/[^a-zA-Z0-9]/g, "_")
.replace(/_{2,}/g, "_");
//Set the fileName on the model
this.set("fileName", trimmedTitle + ".xml");
},
trickleUpChange: function () {
if (
!MetacatUI.rootDataPackage ||
!MetacatUI.rootDataPackage.packageModel
)
return;
//Mark the package as changed
MetacatUI.rootDataPackage.packageModel.set("changed", true);
},
/**
* Sets the xsi:schemaLocation attribute on the passed-in Element
* depending on the application configuration.
*
* @param {Element} eml: The root eml:eml element to modify
* @return {Element} The element, possibly modified
*/
setSchemaLocation: function (eml) {
if (!MetacatUI || !MetacatUI.appModel) {
return eml;
}
var current = $(eml).attr("xsi:schemaLocation"),
format = MetacatUI.appModel.get("editorSerializationFormat"),
location = MetacatUI.appModel.get("editorSchemaLocation");
// Return now if we can't do anything anyway
if (!format || !location) {
return eml;
}
// Simply add if the attribute isn't present to begin with
if (!current || typeof current !== "string") {
$(eml).attr("xsi:schemaLocation", format + " " + location);
return eml;
}
// Don't append if it's already present
if (current.indexOf(format) >= 0) {
return eml;
}
$(eml).attr("xsi:schemaLocation", current + " " + location);
return eml;
},
createID: function () {
this.set("xmlID", uuid.v4());
},
/**
* Creates and adds an {@link EMLAnnotation} to this EML211 model with the given annotation data in JSON form.
* @param {object} annotationData The attribute data to set on the new {@link EMLAnnotation}. See {@link EMLAnnotation#defaults} for
* details on what attributes can be passed to the EMLAnnotation. In addition, there is an `elementName` property.
* @property {string} [annotationData.elementName] The name of the EML Element that this
annotation should be applied to. e.g. dataset, entity, attribute. Defaults to `dataset`. NOTE: Right now only dataset annotations are supported until
more annotation editing is added to the EML Editor.
* @property {Boolean} [annotationData.allowDuplicates] If false, this annotation will replace all annotations already set with the same propertyURI.
* By default, more than one annotation with a given propertyURI can be added (defaults to true)
*/
addAnnotation: function (annotationData) {
try {
if (!annotationData || typeof annotationData != "object") {
return;
}
//If no element name is provided, default to the dataset element.
let elementName = "";
if (!annotationData.elementName) {
elementName = "dataset";
} else {
elementName = annotationData.elementName;
}
//Remove the elementName property so it isn't set on the EMLAnnotation model later.
delete annotationData.elementName;
//Check if duplicates are allowed
let allowDuplicates = annotationData.allowDuplicates;
delete annotationData.allowDuplicates;
//Create a new EMLAnnotation model
let annotation = new EMLAnnotation(annotationData);
//Update annotations set on the dataset element
if (elementName == "dataset") {
let annotations = this.get("annotations");
//If the current annotations set on the EML model are not in Array form, change it to an array
if (!annotations) {
annotations = new EMLAnnotations();
}
if (allowDuplicates === false) {
//Add the EMLAnnotation to the collection, making sure to remove duplicates first
annotations.replaceDuplicateWith(annotation);
} else {
annotations.add(annotation);
}
//Set the annotations and force the change to be recognized by the model
this.set("annotations", annotations, { silent: true });
this.handleChange(this, { force: true });
} else {
/** @todo Add annotation support for other EML Elements */
}
} catch (e) {
console.error("Could not add Annotation to the EML: ", e);
}
},
/**
* Finds annotations that are of the `data sensitivity` property from the NCEAS SENSO ontology.
* Returns undefined if none are found. This function returns EMLAnnotation models because the data
* sensitivity is stored in the EML Model as EMLAnnotations and added to EML as semantic annotations.
* @returns {EMLAnnotation[]|undefined}
*/
getDataSensitivity: function () {
try {
let annotations = this.get("annotations");
if (annotations) {
let found = annotations.where({
propertyURI: this.get("dataSensitivityPropertyURI"),
});
if (!found || !found.length) {
return;
} else {
return found;
}
} else {
return;
}
} catch (e) {
console.error("Failed to get Data Sensitivity from EML model: ", e);
return;
}
},
},
);

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.

validate: function () {
let errors = {};
//A title is always required by EML
if (!this.get("title").length || !this.get("title")[0]) {
errors.title = "A title is required";
}
// Validate the publication date
if (this.get("pubDate") != null) {
if (!this.isValidYearDate(this.get("pubDate"))) {
errors["pubDate"] = [
"The value entered for publication date, '" +
this.get("pubDate") +
"' is not a valid value for this field. Enter with a year (e.g. 2017) or a date in the format YYYY-MM-DD.",
];
}
}
// Validate the temporal coverage
errors.temporalCoverage = [];
//If temporal coverage is required and there aren't any, return an error
if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
!this.get("temporalCoverage").length
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is required and they are all empty, return an error
else if (
MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
})
) {
errors.temporalCoverage = [{ beginDate: "Provide a begin date." }];
}
//If temporal coverage is not required, validate each one
else if (
this.get("temporalCoverage").length ||
(MetacatUI.appModel.get("emlEditorRequiredFields").temporalCoverage &&
_.every(this.get("temporalCoverage"), function (tc) {
return tc.isEmpty();
}))
) {
//Iterate over each temporal coverage and add it's validation errors
_.each(this.get("temporalCoverage"), function (temporalCoverage) {
if (!temporalCoverage.isValid() && !temporalCoverage.isEmpty()) {
errors.temporalCoverage.push(temporalCoverage.validationError);
}
});
}
//Remove the temporalCoverage attribute if no errors were found
if (errors.temporalCoverage.length == 0) {
delete errors.temporalCoverage;
}
//Validate the EMLParty models
var partyTypes = [
"associatedParty",
"contact",
"creator",
"metadataProvider",
"publisher",
];
_.each(
partyTypes,
function (type) {
var people = this.get(type);
_.each(
people,
function (person, i) {
if (!person.isValid()) {
if (!errors[type]) errors[type] = [person.validationError];
else errors[type].push(person.validationError);
}
},
this,
);
},
this,
);
//Validate the EMLGeoCoverage models
_.each(
this.get("geoCoverage"),
function (geoCoverageModel, i) {
if (!geoCoverageModel.isValid()) {
if (!errors.geoCoverage)
errors.geoCoverage = [geoCoverageModel.validationError];
else errors.geoCoverage.push(geoCoverageModel.validationError);
}
},
this,
);
//Validate the EMLTaxonCoverage model
var taxonModel = this.get("taxonCoverage")[0];
if (!taxonModel.isEmpty() && !taxonModel.isValid()) {
errors = _.extend(errors, taxonModel.validationError);
} else if (
taxonModel.isEmpty() &&
this.get("taxonCoverage").length == 1 &&
MetacatUI.appModel.get("emlEditorRequiredFields").taxonCoverage
) {
taxonModel.isValid();
errors = _.extend(errors, taxonModel.validationError);
}
//Validate each EMLEntity model
_.each(this.get("entities"), function (entityModel) {
if (!entityModel.isValid()) {
if (!errors.entities)
errors.entities = [entityModel.validationError];
else errors.entities.push(entityModel.validationError);
}
});
//Validate the EML Methods
let emlMethods = this.get("methods");
if (emlMethods) {
if (!emlMethods.isValid()) {
errors.methods = emlMethods.validationError;
}
}
// Validate the EMLAnnotation models
const annotations = this.get("annotations");
const annotationErrors = annotations.validate();
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
for ([field, isRequired] of Object.entries(
MetacatUI.appModel.get("emlEditorRequiredFields"),
)) {
//If it's not required, then go to the next field
if (!isRequired) continue;
if (field == "alternateIdentifier") {
if (
!this.get("alternateIdentifier").length ||
_.every(this.get("alternateIdentifier"), function (altId) {
return altId.trim() == "";
})
)
errors.alternateIdentifier =
"At least one alternate identifier is required.";
} else if (field == "generalTaxonomicCoverage") {
if (
!this.get("taxonCoverage").length ||
!this.get("taxonCoverage")[0].get("generalTaxonomicCoverage")
)
errors.generalTaxonomicCoverage =
"Provide a description of the general taxonomic coverage of this data set.";
} else if (field == "geoCoverage") {
if (!this.get("geoCoverage").length)
errors.geoCoverage = "At least one location is required.";
} else if (field == "intellectualRights") {
if (!this.get("intellectualRights"))
errors.intellectualRights =
"Select usage rights for this data set.";
} else if (field == "studyExtentDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("studyExtentDescription")
)
errors.studyExtentDescription =
"Provide a study extent description.";
} else if (field == "samplingDescription") {
if (
!this.get("methods") ||
!this.get("methods").get("samplingDescription")
)
errors.samplingDescription = "Provide a sampling description.";
} else if (field == "temporalCoverage") {
if (!this.get("temporalCoverage").length)
errors.temporalCoverage =
"Provide the date(s) for this data set.";
} else if (field == "taxonCoverage") {
if (!this.get("taxonCoverage").length)
errors.taxonCoverage =
"At least one taxa rank and value is required.";
} else if (field == "keywordSets") {
if (!this.get("keywordSets").length)
errors.keywordSets = "Provide at least one keyword.";
}
//The EMLMethods model will validate itself for required fields, but
// this is a rudimentary check to make sure the EMLMethods model was created
// in the first place
else if (field == "methods") {
if (!this.get("methods"))
errors.methods = "At least one method step is required.";
} else if (field == "funding") {
// Note: Checks for either the funding or award element. award
// element is checked by the project's objectDOM for now until
// EMLProject fully supports the award element
if (
!this.get("project") ||
!(
this.get("project").get("funding").length ||
(this.get("project").get("objectDOM") &&
this.get("project").get("objectDOM").querySelectorAll &&
this.get("project").get("objectDOM").querySelectorAll("award")
.length > 0)
)
)
errors.funding =
"Provide at least one project funding number or name.";
} else if (field == "abstract") {
if (!this.get("abstract").length)
errors["abstract"] = "Provide an abstract.";
} else if (field == "dataSensitivity") {
if (!this.getDataSensitivity()) {
errors["dataSensitivity"] =
"Pick the category that best describes the level of sensitivity or restriction of the data.";
}
}
//If this is an EMLParty type, check that there is a party of this type in the model
else if (
EMLParty.prototype.partyTypes
.map((t) => t.dataCategory)
.includes(field)
) {
//If this is an associatedParty role
if (EMLParty.prototype.defaults().roleOptions?.includes(field)) {
if (
!this.get("associatedParty")
?.map((p) => p.get("roles"))
.flat()
.includes(field)
) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field)?.length) {
errors[field] =
"Provide information about the people or organization(s) in the role: " +
EMLParty.prototype.partyTypes.find(
(t) => t.dataCategory == field,
)?.label;
}
} else if (!this.get(field) || !this.get(field)?.length) {
errors[field] = "Provide a " + field + ".";
}
}
if (Object.keys(errors).length) return errors;
else {
return;
}
},

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Allow editors to identify the canonical data package in the Editor
1 participant