define([
"backbone",
"common/EMLUtilities",
"collections/metadata/eml/EMLAttributes",
"models/metadata/eml211/EMLReferences",
"models/DataONEObject",
], (Backbone, EMLUtilities, EMLAttributes, EMLReferences, DataONEObject) => {
/**
* @class EMLAttributeList
* @classdesc A model representing the EML AttributeListType.
* See {@link https://eml.ecoinformatics.org/schema/eml-attribute_xsd#AttributeListType}
* @classcategory Models/Metadata/EML211
* @constructs
* @augments Backbone.Model
*/
const EMLAttributeList = Backbone.Model.extend(
/** @lends EMLAttributeList.prototype */
{
/**
* Default attributes for this model
* @returns {object} Default attributes
* @property {string} xmlID - The XML ID of the attribute list
* @property {EMLAttributes} emlAttributes - The collection of EML
* attributes. Note: This cannot be called "attributes" because that
* overrides the Backbone.Model.attributes property and causes unexpected
* behaviour.
* @property {EMLReferences} references - A reference to another
* attributeList in the same EML document
* @property {EMLEntity} parentModel - The parent model of this attribute
* list
*/
defaults() {
return {
xmlID: DataONEObject.generateId(),
emlAttributes: new EMLAttributes(),
references: null,
parentModel: null,
};
},
/** @inheritdoc */
initialize(attrs = {}, options = {}) {
if (!attrs?.parentModel && options?.parentModel) {
this.set("parentModel", options.parentModel);
}
// Listen for both changes on the eml attributes collection and for
// replacement of the collection itself, in which case we need to reset
// listeners.
this.listenTo(this, "change:emlAttributes", this.listenToAttributes);
this.listenToAttributes();
// Similar with references
this.listenTo(this, "change:references", this.listenToReferences);
this.listenToReferences();
},
/**
* Trigger a change:emlAttributes event on this model when the
* emlAttributes collection within this model adds, removes, or changes
* one if it's models. This event can be used to notify other views/models
* that the attributes collection has changed OR been replaced with a new
* collection.
*/
listenToAttributes() {
// Stop listening to previous collection
const prevAttr = this.previous("emlAttributes");
if (prevAttr) this.stopListening(prevAttr);
// Get the current collection
const attrs = this.get("emlAttributes");
if (!attrs) return;
// Listen to changes in the collection
this.stopListening(attrs, "update change");
this.listenTo(attrs, "update change", () => {
this.trigger("change:emlAttributes", this, attrs);
});
},
/**
* Trigger a change:references event on this model when the references
* model changes which model it references. This event can be used to
* notify other views/models that the references model has changed or when
* it has been replaced with a new model.
*/
listenToReferences() {
const prevRef = this.previous("references");
if (prevRef) this.stopListening(prevRef);
const ref = this.get("references");
if (!ref) return;
this.stopListening(ref);
this.listenToOnce(ref, "change:references", () => {
this.trigger("change:references", this, ref);
});
},
/**
* Node names as they appear in the XML document mapped to how they are
* parsed using the jquery html parser (used for historical reasons).
* @returns {object} A map of node names in lowercase to their
* corresponding xml camelCase names.
*/
nodeNameMap() {
const nodeNames = ["attributeList", "attribute", "references"];
// convert to lowercase : camelCase map
return nodeNames.reduce((acc, nodeName) => {
acc[nodeName.toLowerCase()] = nodeName;
return acc;
}, {});
},
/** @inheritdoc */
parse(response, options = {}) {
if (!response || !response.length) {
return {};
}
// Convert the jquery object to a DOM element so we can use native DOM
// methods. Note the element has the HTML namespace because it was
// originally parsed with jquery's HTMLparse. Removed during
// serialization.
const dom = response.get(0);
const id = dom.getAttribute("id");
const parentModel = options?.parentModel;
const thisAttrList = this;
let emlAttributes = new EMLAttributes({ ...options });
let references = null;
// An attribute list may have references OR attributes, but not both.
const referencesNode = dom.getElementsByTagName("references");
if (referencesNode?.length) {
// Only one reference is allowed
const refNode = referencesNode[0];
const refOpts = {
...options,
parentModel: thisAttrList,
parse: true,
modelType: "EMLAttributeList",
};
references = new EMLReferences(refNode, refOpts);
} else {
const collectionOpts = {
...options,
parentModel: thisAttrList,
parse: true,
};
emlAttributes = new EMLAttributes(dom, collectionOpts);
}
return {
xmlID: id,
emlAttributes,
references,
parentModel,
objectDOM: dom,
};
},
/**
* Update the EML AttributeList DOM element with the current state of the
* model.
* @param {Element} [currentDOM] - The current DOM element to update. If
* not provided, the model's objectDOM will be used, or a new element will
* be created if none exists.
* @returns {Element} The updated DOM element
*/
updateDOM(currentDOM) {
let dom = currentDOM || this.get("objectDOM");
if (!dom) {
dom = document.createElementNS(null, "attributeList");
} else {
dom = dom.cloneNode(true);
}
if (this.isEmpty()) {
dom.remove();
this.set("objectDOM", null);
return null;
}
if (dom.childNodes.length) {
// Remove all existing attributes
while (dom.firstChild) {
dom.removeChild(dom.firstChild);
}
}
const refDom = this.get("references")?.updateDOM();
let keepId = true;
if (refDom) {
// replace the existing references node with the new one
const oldRef = dom.getElementsByTagName("references");
if (oldRef.length) {
dom.replaceChild(refDom, oldRef[0]);
} else {
dom.appendChild(refDom);
}
// IDs on attrList nodes with references are not legal
keepId = false;
// Can only have reference OR attributes, not both
} else {
// Add each attribute from the collection to the DOM
this.get("emlAttributes")?.each((attribute) => {
if (!attribute.isEmpty()) {
const updatedAttrDOM = attribute.updateDOM();
dom.append(updatedAttrDOM);
}
});
}
// Set the XML ID of the attribute list
const xmlID = this.get("xmlID");
if (xmlID && keepId) {
dom.setAttribute("id", xmlID);
} else {
dom.removeAttribute("id");
}
this.set("objectDOM", dom);
return dom;
},
/**
* Serialize this model to EML XML
* @returns {string} XML string representing the attribute list
*/
serialize() {
const dom = this.updateDOM();
if (!dom) return null;
// Convert the DOM element to a string
const serializer = new XMLSerializer();
let xmlString = serializer.serializeToString(dom);
// Remove the XML declaration
xmlString = xmlString.replace(/<\?xml.*?\?>/, "");
// Remove the namespace uri
xmlString = xmlString.replace(/xmlns="[^"]*"/, "");
return DataONEObject.prototype.formatXML.call(this, xmlString);
},
/**
* Check if the attribute list is empty. An attribute list is empty if
* it has no attributes and no references.
* @returns {boolean} True if the attribute list is empty, false
* otherwise
*/
isEmpty() {
return !this.hasNonEmptyAttributes() && !this.hasReferences();
},
/**
* Check if the attribute list has any attributes that have values other
* than the default values.
* @returns {boolean} False if the attribute list is empty or the attributes
* are all empty, true otherwise.
*/
hasNonEmptyAttributes() {
return this.get("emlAttributes")?.hasNonEmptyAttributes();
},
/**
* Check if the attribute list has a references element.
* @returns {boolean} True if the attribute list has a references with a
* value, false otherwise
*/
hasReferences() {
return this.get("references") && !this.get("references").isEmpty();
},
/**
* Validate the model state to conform to the EML schema rules.
* @returns {object|false} Validation errors or false if valid
*/
validate() {
if (this.isEmpty()) return false;
const errors = {};
const emlAttributes = this.get("emlAttributes");
const references = this.get("references");
const hasAttributes = emlAttributes?.hasNonEmptyAttributes();
const hasReferences = references && !references.isEmpty();
if (hasAttributes && hasReferences) {
errors.attributes =
"An attribute list must contain either attribute elements or references, not both.";
errors.references =
"An attribute list must contain either attribute elements or references, not both.";
return errors;
}
const refErrors = references?.validate();
const attributeErrors = emlAttributes?.validate();
// if there are validation errors, add them to the errors object. An
// empty object is valid.
if (refErrors && Object.keys(refErrors).length) {
errors.references = refErrors;
}
if (attributeErrors && Object.keys(attributeErrors).length) {
errors.attributes = attributeErrors;
}
return Object.keys(errors).length ? errors : false;
},
/** Removes the references from the list and destroys the refs model */
removeReferences() {
const references = this.get("references");
if (references) {
references.destroy();
this.set("references", null);
}
},
/** Empty the attribute list if there is one */
removeAttributes() {
const emlAttributes = this.get("emlAttributes");
if (emlAttributes) {
emlAttributes.each((attribute) => {
emlAttributes.remove(attribute);
attribute.destroy();
});
}
},
/**
* Set the references for this attribute list. This will remove any
* existing attributes since a list cannot have both attributes and
* references.
* @param {string} id - The ID of the referenced attribute list
*/
setReferences(id) {
if (!id) {
throw new Error("ID is required to set references");
}
this.removeAttributes();
const references = this.get("references");
if (references) {
references.set("references", id);
} else {
this.set(
"references",
new EMLReferences({
references: id,
parentModel: this,
modelType: "EMLAttributeList",
}),
);
}
},
/**
* Get the EML document that contains this attribute list
* @returns {EML} The EML document that contains this attribute list
*/
getParentEML() {
return EMLUtilities.getParentEML(this);
},
/**
* Given an array of strings, update the names of the attributes in the
* collection to match the array. If the number of names in the array
* exceeds the number of attributes in the collection, new attributes will
* be added to the collection. If the number of names is less than the
* number of attributes in the collection, the extra attributes will be
* removed.
*
* Better than calling updateNames on emlAttributes directly, since this
* method handles removing references if present and adding an attrs
* collection if missing
* @param {string[]} names - An array of new attribute names
*/
updateAttributeNames(names) {
if (!names || !Array.isArray(names) || !names.length) {
throw new Error("Names must be an array");
}
// If there are references, remove them. updateNames will add attributes
// and it's illegal to have both references and attributes in the same
// attribute list.
const references = this.get("references");
if (references && !references.isEmpty()) {
this.removeReferences();
}
// If there's no attributes collection, create one
if (!this.get("emlAttributes")) {
this.set("emlAttributes", new EMLAttributes());
}
const emlAttributes = this.get("emlAttributes");
emlAttributes.updateNames(names, this);
},
},
);
return EMLAttributeList;
});