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
218 changes: 167 additions & 51 deletions src/js/collections/metadata/eml/EMLAnnotations.js
Original file line number Diff line number Diff line change
@@ -1,75 +1,191 @@
"use strict";

define([
"jquery",
"underscore",
"backbone",
"models/metadata/eml211/EMLAnnotation",
], function ($, _, Backbone, EMLAnnotation) {
define(["underscore", "backbone", "models/metadata/eml211/EMLAnnotation"], (
_,
Backbone,
EMLAnnotation,
) => {
const SCHEMA_ORG_SAME_AS = "http://www.w3.org/2002/07/owl#sameAs";
const PROV_WAS_DERIVED_FROM = "http://www.w3.org/ns/prov#wasDerivedFrom";
/**
* @class EMLAnnotations
* @classdesc A collection of EMLAnnotations.
* @classcategory Collections/Metadata/EML
* @since 2.19.0
* @extends Backbone.Collection
* @augments Backbone.Collection
*/
var EMLAnnotations = Backbone.Collection.extend(
const EMLAnnotations = Backbone.Collection.extend(
/** @lends EMLAnnotations.prototype */
{
/** @inheritdoc */
model: EMLAnnotation,

/**
* The reference to the model class that this collection is made of.
* @type EMLAnnotation
* @since 2.19.0
* Checks if this collection already has an annotation for the same
* property URI.
* @param {EMLAnnotation} annotation The EMLAnnotation to compare against
* the annotations already in this collection.
* @returns {boolean} Returns true is this collection already has an
* annotation for this property.
*/
model: EMLAnnotation,
hasDuplicateOf(annotation) {
// If there is at least one model in this collection and there is a
// propertyURI set on the given model,
if (this.length && annotation.get("propertyURI")) {
// Return whether or not there is a duplicate
const properties = this.pluck("propertyURI");
return properties.includes(annotation.get("propertyURI"));
}
// If this collection is empty or the propertyURI is falsey, return
// false
return false;
},

/**
* Checks if this collection already has an annotation for the same property URI.
* @param {EMLAnnotation} annotation The EMLAnnotation to compare against the annotations already in this collection.
* @returns {Boolean} Returns true is this collection already has an annotation for this property.
* @since 2.19.0
* Removes the EMLAnnotation from this collection that has the same
* propertyURI as the given annotation. Then adds the given annotation to
* the collection. If no duplicate is found, the given annotation is still
* added to the collection.
* @param {EMLAnnotation} annotation The EMLAnnotation to replace
* duplicates with.
*/
hasDuplicateOf: function (annotation) {
try {
//If there is at least one model in this collection and there is a propertyURI set on the given model,
if (this.length && annotation.get("propertyURI")) {
//Return whether or not there is a duplicate
let properties = this.pluck("propertyURI");
return properties.includes(annotation.get("propertyURI"));
}
//If this collection is empty or the propertyURI is falsey, return false
else {
return false;
}
} catch (e) {
console.error("Could not check for a duplicate annotation: ", e);
return false;
replaceDuplicateWith(annotation) {
if (this.length && annotation.get("propertyURI")) {
const duplicates = this.findWhere({
propertyURI: annotation.get("propertyURI"),
});
this.remove(duplicates);
}
this.add(annotation);
},

/**
* Find all annotations with the given propertyURI.
* @param {string} propertyURI The propertyURI to search for.
* @returns {EMLAnnotation[]} An array of EMLAnnotations with the given
* propertyURI.
* @since 0.0.0
*/
findByProperty(propertyURI) {
return this.where({ propertyURI });
},

/**
* Removes the EMLAnnotation from this collection that has the same propertyURI as the given annotation.
* Then adds the given annotation to the collection. If no duplicate is found, the given annotation is still added
* to the collection.
* @param {EMLAnnotation} annotation
* @since 2.19.0
* Adds canonical dataset annotations to this collection. A canonical
* dataset is the one that is considered the authoritative version; the
* current EML doc being essentially a duplicate version.
* @param {string} sourceId The DOI or URL of the canonical dataset.
* @returns {void}
* @since 0.0.0
*/
replaceDuplicateWith: function (annotation) {
try {
if (this.length && annotation.get("propertyURI")) {
let duplicates = this.findWhere({
propertyURI: annotation.get("propertyURI"),
});
this.remove(duplicates);
}

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

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

/**
* Find the annotations that make up the canonical dataset annotation. A
* canonical dataset is identified by having both a "derivedFrom" and a
* "sameAs" annotation with the same DOI or URL for the valueURI.
* @returns {object} An object with the derivedFrom and sameAs
* annotations.
* @since 0.0.0
*/
findCanonicalDatasetAnnotation() {
// There must be at least one derivedFrom and one sameAs annotation
// for this to have a canonical dataset annotation
if (!this.length) return null;
const derivedFrom = this.findByProperty(PROV_WAS_DERIVED_FROM);
if (!derivedFrom?.length) return null;
const sameAs = this.findByProperty(SCHEMA_ORG_SAME_AS);
if (!sameAs?.length) return null;

// Find all pairs that have matching valueURIs
const pairs = [];
derivedFrom.forEach((derived) => {
sameAs.forEach((same) => {
if (derived.get("valueURI") === same.get("valueURI")) {
// TODO? Check that the URI is a valid DOI or URL
pairs.push({ derived, same, uri: derived.get("valueURI") });
}
});
});

// If there are multiple pairs, we cannot determine which is the
// canonical dataset.
if (pairs.length > 1 || !pairs.length) return null;

// There is only one pair, so return it
return pairs[0];
},

/**
* Updates the canonical dataset annotations to have the given ID. If
* there is no canonical dataset annotation, one is added. If the ID is a
* falsy value, the canonical dataset annotation is removed.
* @param {string} newSourceId The DOI or URL of the canonical dataset.
* @returns {object} An object with the derivedFrom and sameAs annotations
* if the canonical dataset annotations were updated.
* @since 0.0.0
*/
updateCanonicalDataset(newSourceId) {
if (!newSourceId) {
this.removeCanonicalDatasetAnnotation();
return null;
}
const canonical = this.findCanonicalDatasetAnnotation();
if (!canonical) {
return this.addCanonicalDatasetAnnotation(newSourceId);
}

const { derived, same, uri } = canonical;
if (uri === newSourceId) return null;

derived.set("valueURI", newSourceId);
derived.set("valueLabel", newSourceId);
same.set("valueURI", newSourceId);
same.set("valueLabel", newSourceId);

return [derived, same];
},

/**
* Removes the canonical dataset annotations from this collection.
* @returns {EMLAnnotation[]} The canonical dataset annotations that were
* removed.
* @since 0.0.0
*/
removeCanonicalDatasetAnnotation() {
const canonical = this.findCanonicalDatasetAnnotation();
if (!canonical) return null;
return this.remove([canonical.derived, canonical.same]);
},

/**
* Returns the URI of the canonical dataset.
* @returns {string} The URI of the canonical dataset.
* @since 0.0.0
*/
getCanonicalURI() {
const canonical = this.findCanonicalDatasetAnnotation();
return canonical?.uri;
},
},
);
Expand Down
29 changes: 29 additions & 0 deletions src/js/models/metadata/eml211/EML211.js
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
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;
}
},

Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ define([
methods: new EMLMethods(), // An EMLMethods objects
project: null, // An EMLProject object,
annotations: null, // Dataset-level annotations
canonicalDataset: null,
dataSensitivityPropertyURI:
"http://purl.dataone.org/odo/SENSO_00000005",
nodeOrder: [
Expand Down Expand Up @@ -143,6 +144,13 @@ define([
this.set("synced", true);
});

this.stopListening(this, "change:canonicalDataset");
this.listenTo(
this,
"change:canonicalDataset",
this.updateCanonicalDataset,
);

//Create a Unit collection
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (!this.units.length) this.createUnits();
},
Expand All @@ -160,6 +168,17 @@ define([
);
},

updateCanonicalDataset() {
let uri = this.get("canonicalDataset");
uri = uri?.length ? uri[0] : null;
let annotations = this.get("annotations");
if (!annotations) {
annotations = new EMLAnnotations();
this.set("annotations", annotations);
}
annotations.updateCanonicalDataset(uri);
},

/*
* Maps the lower-case EML node names (valid in HTML DOM) to the camel-cased EML node names (valid in EML).
* Used during parse() and serialize()
Expand Down Expand Up @@ -734,6 +753,16 @@ define([
}
}

// Once all the nodes have been parsed, check if any of the annotations
// make up a canonical dataset reference
const annotations = modelJSON["annotations"];
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
if (annotations) {
const canonicalDataset = annotations.getCanonicalURI();
if (canonicalDataset) {
modelJSON["canonicalDataset"] = canonicalDataset;
robyngit marked this conversation as resolved.
Show resolved Hide resolved
}
}

return modelJSON;
},

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

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

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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

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

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

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

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

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

/* Let the top level package know of attribute changes from this object */
trickleUpChange: function () {
robyngit marked this conversation as resolved.
Show resolved Hide resolved
robyngit marked this conversation as resolved.
Show resolved Hide resolved
MetacatUI.rootDataPackage.packageModel.set("changed", true);
MetacatUI.rootDataPackage.packageModel?.set("changed", true);
},
},
);
Expand Down
8 changes: 8 additions & 0 deletions src/js/templates/metadata/metadataOverview.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,11 @@ <h5>Alternate Identifiers <i class="required-icon hidden" data-category="alterna
list any additional identifiers that can be used to locate or label the dataset here.</p>
<p class="notification" data-category="alternateIdentifier"></p>
</div>

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

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

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

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

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

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

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

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