/* eslint-disable max-classes-per-file */
"use strict";
define([
"backbone",
"models/metadata/eml211/EMLEntity",
"models/metadata/eml211/EMLDataTable",
"models/metadata/eml211/EMLOtherEntity",
"models/DataONEObject",
"common/XMLUtilities",
], (
Backbone,
EMLEntity,
EMLDataTable,
EMLOtherEntity,
DataONEObject,
XMLUtilities,
) => {
// The names of the nodes that are considered entities in EML
const ENTITY_NODE_NAMES = [
"otherEntity",
"dataTable",
"spatialRaster",
"spatialVector",
"storedProcedure",
"view",
];
const DEFAULT_FORMAT_ID = "application/octet-stream";
/**
* Snapshot the fields needed to match an EML entity. The source may be a
* legacy Backbone DataONEObject or a property-based package member.
* @param {DataONEObject|object} obj The data object
* @returns {object} Stable values used by the EML models
*/
const getObjectDescriptor = (obj) => {
if (!obj) return {};
const isBackboneModel = typeof obj.get === "function";
const id = isBackboneModel
? obj.get("id")
: obj.pid || obj.id || obj.identifier;
const fileName = isBackboneModel
? obj.get("fileName")
: obj.getFileName?.() || obj.fileName || obj.filename;
const formatId = isBackboneModel
? obj.get("formatId") || obj.get("mediaType")
: obj.getFormatId?.() ||
obj.formatId ||
obj.mediaType ||
obj.sysMeta?.formatId;
return {
id,
fileName,
formatId,
xmlID: XMLUtilities.getXMLSafeID(id),
checksum: isBackboneModel
? obj.get("checksum")
: obj.checksum || obj.sysMeta?.checksum?.value || obj.sysMeta?.checksum,
checksumAlgorithm: isBackboneModel
? obj.get("checksumAlgorithm")
: obj.checksumAlgorithm ||
obj.sysMeta?.checksum?.algorithm ||
obj.sysMeta?.checksumAlgorithm,
metadataEntity: isBackboneModel ? obj.get("metadataEntity") : null,
previousFileName: isBackboneModel
? obj.previous("fileName")
: obj.remoteSysMeta?.fileName,
previousFormatId: isBackboneModel
? obj.previous("formatId") || obj.previous("mediaType")
: obj.remoteSysMeta?.formatId,
};
};
/**
* @class InvalidAttributeListError
* @classdesc An error that is thrown when an invalid attribute list is
* encountered.
* @classcategory Errors
* @since 2.34.0
*/
class InvalidAttributeListError extends Error {
constructor(message) {
super(message);
this.name = "InvalidAttributeListError";
}
}
/**
* @class EmptyAttributeListError
* @classdesc An error that is thrown when an empty attribute list is
* encountered.
* @classcategory Errors
* @since 2.34.0
*/
class EmptyAttributeListError extends Error {
constructor(message) {
super(message);
this.name = "EmptyAttributeListError";
}
}
/**
* @class EMLEntities
* @classdesc A collection of EMLEntities.
* @classcategory Collections/Metadata/EML
* @since 2.33.0
* @augments Backbone.Collection
*/
const EMLEntities = Backbone.Collection.extend(
/** @lends EMLEntities.prototype */
{
/** @inheritdoc */
// eslint-disable-next-line object-shorthand, func-names
model: function (attrs, options = {}) {
// the name of the eml node determines the model to use
const objDOM = attrs.objectDOM;
const type =
attrs.type?.toLowerCase() ||
objDOM?.localName?.toLowerCase() ||
objDOM?.nodeName?.toLowerCase();
// the parent model is needed for functionality in the model
const modifiedAttrs = { ...attrs };
if (!attrs?.parentModel && options?.parentModel) {
modifiedAttrs.parentModel = options.parentModel;
}
switch (type) {
case "otherentity":
return new EMLOtherEntity(modifiedAttrs, options);
case "datatable":
return new EMLDataTable(modifiedAttrs, options);
default:
return new EMLEntity(
{
entityType: DEFAULT_FORMAT_ID,
type,
...modifiedAttrs,
},
options,
);
}
},
/** @inheritdoc */
parse(response, _options) {
const entities =
response.datasetNode?.querySelectorAll(ENTITY_NODE_NAMES.join(",")) ||
[];
return Array.from(entities).map((entity) => ({
objectDOM: entity,
parentModel: response.parentModel,
}));
},
/** @inheritdoc */
validate() {
const errors = [];
// Validate each of the EMLEntities
this.each((model) => {
if (!model.isValid()) {
errors.push(model.validationError);
}
});
return errors;
},
/**
* Update an EML dataset node with the entities in this collection. Will
* add, remove, or update entities and re-order according to the order in
* this collection.
* @param {Element} datasetNode The dataset node to update
* @param {EML211} eml The EML model that contains the dataset node
*/
updateDatasetDOM(datasetNode, eml) {
const existingEntities = Array.from(
datasetNode.querySelectorAll(ENTITY_NODE_NAMES.join(",")),
);
const emlModel = this.getParentModel();
this.each((entity, i) => {
// Replace or append node
const existingEntity = existingEntities[i];
if (existingEntity) existingEntity.remove();
const nodeName = entity.get("type").toLowerCase();
const position = emlModel.getEMLPosition(eml, nodeName);
const updatedEntityDOM = entity.updateDOM();
if (position?.length) {
// position is a jQuery object
position.after(updatedEntityDOM);
} else {
datasetNode.appendChild(updatedEntityDOM);
}
});
// Remove extra nodes if any
const extraEntities = existingEntities.length - this.length;
if (extraEntities > 0) {
const startIndex = existingEntities.length - extraEntities;
existingEntities.slice(startIndex).forEach((node) => node.remove());
}
},
/**
* Add a new entity using a data object or package member descriptor.
* Sets listeners to remove the entity if a legacy DataONE object fails to
* save, and to add it back if it later saves successfully.
* @param {DataONEObject|object} dataObject Data object or member descriptor
* @param {object} options Options for the entity
* @param {EMLModel} options.parentModel The parent model of the entity
* @returns {EMLEntity} The new entity that was added to the collection
*/
addFromDataONEObject(dataObject, options = {}) {
const descriptor = getObjectDescriptor(dataObject);
const entity = this.add({
entityName: descriptor.fileName,
entityType: descriptor.formatId || DEFAULT_FORMAT_ID,
downloadID: descriptor.id || null,
fileName: descriptor.fileName || null,
formatId: descriptor.formatId || null,
parentModel: options.parentModel || this.getParentModel(),
xmlID: options.xmlID || descriptor.xmlID || null,
// Important: Adding as a generic entity creates invalid EML
type: "otherEntity",
});
if (
typeof dataObject?.on === "function" &&
typeof dataObject?.off === "function"
) {
this.stopListening(dataObject);
this.listenTo(dataObject, "errorSaving", () => {
this.remove(entity);
// Listen for a successful save so the entity can be added back
this.listenToOnce(dataObject, "successSaving", () => {
this.add(entity);
});
});
}
return entity;
},
/**
* Search the collection for an entity that matches a data object or
* package member. Matches are made based on its identifier,
* checksum, file name, or format type. Optionally, a DataPackage
* collection can be provided to assess whether the entity is the only one
* in the package, and therefore must be the entity for the given object.
* @param {DataONEObject|object} dataObject Data object or member descriptor
* @param {DataPackage} [dataPackage] The DataPackage collection to check
* @returns {EMLEntity|boolean} The matching EMLEntity model or false if
* no match is found
*/
getByDataONEObject(dataObject, dataPackage) {
const descriptor = getObjectDescriptor(dataObject);
// If an EMLEntity model has been found for this object before, consider
// it a match.
const objID = descriptor.id;
const objXMLID = descriptor.xmlID;
const objFileName = descriptor.fileName;
let foundEntity =
descriptor.metadataEntity ||
(objID ? this.find((ent) => ent.getDataPid() === objID) : null);
const objFormatName = descriptor.formatId?.toLowerCase();
if (!foundEntity) {
// Gather information about the DataONE object
const objCheckSum = descriptor.checksum;
const objCheckSumIsMD5 =
descriptor.checksumAlgorithm?.toUpperCase() === "MD5";
const standardFileName = objFileName?.toLowerCase();
foundEntity = this.find((ent) => {
// Matches of the checksum or identifier are definite matches
if (objXMLID && objXMLID === ent.get("xmlID")) return true;
const entCheckSum = ent.get("physicalMD5Checksum");
if (objCheckSumIsMD5 && objCheckSum && objCheckSum === entCheckSum)
return true;
if (objID && objID === ent.get("downloadID")) return true;
// 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.
if (standardFileName) {
const fileNameMatches = this.getByFileName(standardFileName);
if (fileNameMatches?.length === 1 && fileNameMatches[0] === ent) {
return true;
}
}
return false;
});
}
// Check if one data object is of this type in the package
if (!foundEntity) {
const formatMatches = this.getByFormatName(objFormatName);
if (formatMatches?.length === 1) [foundEntity] = formatMatches;
}
// 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 (
!foundEntity &&
this.length === 1 &&
dataPackage?.length === 2 &&
dataPackage.models.includes(dataObject)
) {
foundEntity = this.at(0);
// TODO: Should we ensure that the entity is in this collection?
}
// Do not replace an existing entity-to-member PID match.
const entityDataPid = foundEntity?.getDataPid();
if (entityDataPid && objID && entityDataPid !== objID) {
foundEntity = false;
}
if (foundEntity) {
if (!foundEntity.get("xmlID")) {
descriptor.xmlID =
this.getParentModel()?.getUniqueEntityId(descriptor) ||
descriptor.xmlID;
}
foundEntity.setMemberDescriptor(descriptor);
if (typeof dataObject.set === "function") {
dataObject.set("metadataEntity", foundEntity);
}
}
return foundEntity || false;
},
/**
* Get all entities in the collection that have the given format name set
* as the entity type.
* @param {string} formatName The format name to search for
* @returns {EMLEntity[]} The entities that have the given format name
*/
getByFormatName(formatName) {
if (!formatName) return null;
return this.filter((entity) => {
const entFormatName = entity.get("entityType")?.toLowerCase();
return entFormatName === formatName.toLowerCase();
});
},
/**
* Get all entities in the collection that have the given file name set as
* the entity name or physical object name.
* @param {string} fileName The file name to search for
* @returns {EMLEntity[]} The entities that have the given file name
*/
getByFileName(fileName) {
const standardFileName = fileName.toLowerCase();
return this.filter((entity) => {
// Get the entity's file name in a standard format
const entFileName = (
entity.get("physicalObjectName") || entity.get("entityName")
)?.toLowerCase();
if (!entFileName) return false;
const entFileNameUnderscored = entFileName?.replace(/ /g, "_");
// Check if the entity's file name matches the given file name
return (
entFileName === standardFileName ||
entFileNameUnderscored === standardFileName
);
});
},
/**
* Get the model that contains this collection. Searches through all of
* the entities in the collection to find the one that has the parentModel
* set.
* @returns {EMLEntity} The model that contains this collection or null if
* no parent model is found
*/
getParentModel() {
// Iterate through the collection until the parent model is found
const attrWithParent = this.find((attr) => attr.get("parentModel"));
return attrWithParent ? attrWithParent.get("parentModel") : null;
},
/**
* Check that the collection has at least one entity that has data.
* @returns {boolean} True if the collection has at least one entity that
* is not empty, false otherwise
*/
hasNonEmptyEntity() {
return this.some((model) => !model.isEmpty());
},
/**
* Get the names of all the entities in the collection.
* @returns {string[]} The names of all the entities in the collection,
* either the entityName or physicalObjectName for each entity
*/
getAllFileNames() {
return this.map(
(entity) =>
entity.get("entityName") || entity.get("physicalObjectName"),
);
},
/**
* Get all entities in the collection that have valid attributes. An
* entity with no attributes is considered valid.
* @returns {EMLEntity[]} The entities that have valid attributes
*/
getEntitiesWithValidAttributes() {
return this.filter((entity) => {
const attrList = entity.get("attributeList");
return attrList && attrList.isValid();
});
},
/**
* Duplicate the attribute list from a source entity to given target
* entities in this collection. Any attributes in the target entities will
* be removed and replaced with the source attributes. Remove events will
* be triggered on the target entities. The attributes are copied over as
* a deep copy, so changes to the new target attributes will not affect
* the source attributes. If any xmlIDs are present in the copied
* attributes, they will be removed.
* @param {EMLEntity} source - The entity to copy attributes from. Must
* contain at least one non-empty attribute.
* @param {EMLEntity[]} targets - The entities to copy attributes to
* @param {boolean} [errorIfInvalid] - If true (default), an error will be
* thrown if any attributes from the source entity are invalid. If set to
* false, only valid attributes will be copied over, and invalid
* attributes will be ignored.
* @param {boolean} [byRef] - If true, the attributes will be copied as a
* reference to the source attributes. Meaning that changes to the source
* attributes will affect the target attributes. If false (default), the
* attributes will have the same values as the source attributes, but will
* be a deep copy. This means that changes to the source attributes will
* not affect the target attributes.
*/
copyAttributeList(source, targets, errorIfInvalid = true, byRef = false) {
if (!source || !targets) return;
const sourceAttrs = source.get("attributeList");
if (!sourceAttrs?.hasNonEmptyAttributes()) {
throw new EmptyAttributeListError(
"Source entity has no attributes or all attributes are empty",
);
}
// Invalid attributes can't be serialized and so can't be copied. Must
// serialize to create deep copies of the attributes.
if (errorIfInvalid && sourceAttrs.validate()) {
const errors = source.get("attributeList").validate();
throw new InvalidAttributeListError(
errors.join ? errors.join("\n") : "Invalid attribute list",
);
}
targets.forEach((target) => {
if (!target) return;
if (byRef) {
this.copyAttributeListByRef(source, target);
} else {
this.deepCopyAttributeList(source, target);
}
});
},
/**
* Copy the attribute list from a source entity to a target entity by
* reference. This means that changes to the source will be reflected in
* the target.
* @param {EMLEntity} source - The entity to copy attributes from
* @param {EMLEntity} target - The entity to copy attributes to
*/
copyAttributeListByRef(source, target) {
const sourceAttrList = source.get("attributeList");
let sourceId = sourceAttrList.get("xmlID");
if (!sourceId) {
sourceId = DataONEObject.generateId();
sourceAttrList.set("xmlID", sourceId);
}
// empty the target attribute list
const targetAttrList = target.get("attributeList");
targetAttrList.setReferences(sourceId);
},
/**
* Deep copy the attribute list from a source entity to a target entity.
* This means that changes to the source will not be reflected in the
* target.
* @param {EMLEntity} source - The entity to copy attributes from
* @param {EMLEntity} target - The entity to copy attributes to
*/
deepCopyAttributeList(source, target) {
const attrsStr = source.get("attributeList").serialize();
const attrList = target.get("attributeList");
const emlAttrs = attrList.get("emlAttributes");
// Use remove rather than reset to trigger events
emlAttrs.remove(emlAttrs.models);
const attrDOM = new DOMParser().parseFromString(attrsStr, "text/xml");
emlAttrs.add(attrDOM, { parse: true });
// remove xmlID from the target attributes
emlAttrs.each((attr) => attr.unset("xmlID"));
// Reference to entity model required for attr & sub-models
emlAttrs.each((attr) => attr.set("parentModel", target));
// Invalid to have both attributes and references
if (attrList.hasReferences()) {
attrList.removeReferences();
}
},
/**
* Get the attribute lists of all entities in the collection.
* @returns {EMLAttributeList[]} The attribute lists of all entities in the
* collection. If an entity does not have an attribute list, null is
* returned for that entity.
*/
getAllAttributeLists() {
return this.map((entity) => {
const attrList = entity.get("attributeList");
return attrList || null;
});
},
},
);
return EMLEntities;
});