/** @exports PortalModel */
define([
"jquery",
"underscore",
"gmaps",
"common/ValueUtilities",
"models/filters/Filter",
"models/portals/PortalSectionModel",
"models/portals/PortalVizSectionModel",
"models/portals/PortalOption",
"models/portals/PortalImage",
"models/metadata/eml211/EMLParty",
"models/metadata/eml220/EMLText",
"models/CollectionModel",
"models/filters/FilterGroup",
"models/Map",
"common/QueryService",
], (
$,
_,
gmaps,
ValueUtilities,
FilterModel,
PortalSectionModel,
PortalVizSectionModel,
PortalOption,
PortalImage,
EMLParty,
EMLText,
CollectionModel,
FilterGroup,
MapModel,
QueryService,
) => {
/**
* @classdesc A PortalModel is a specialized collection that represents a
* portal, including the associated data, people, portal descriptions, results
* and visualizations. It also includes settings for customized filtering of
* the associated data, and properties used to customized the map display and
* the overall branding of the portal.
* @classcategory Models/Portals
* @augments CollectionModel
* @module models/PortalModel
* @name PortalModel
* @class PortalModel
*/
const PortalModel = CollectionModel.extend(
/** @lends PortalModel.prototype */ {
/**
* The name of this type of model
* @type {string}
*/
type: "Portal",
/**
* Overrides the default Backbone.Model.defaults() function to specify
* default attributes for the portal model
* @returns {object} The default portal attributes
*/
defaults() {
return _.extend(CollectionModel.prototype.defaults(), {
id: null,
objectXML: null,
formatId: MetacatUI.appModel.get("portalEditorSerializationFormat"),
formatType: "METADATA",
type: "portal",
// Is true if the last fetch was sent with user credentials. False if
// not.
fetchedWithAuth: null,
logo: null,
sections: [],
associatedParties: [],
acknowledgments: null,
acknowledgmentsLogos: [],
awards: [],
checkedNodeLabels: false,
labelDoubleChecked: false,
literatureCited: [],
filterGroups: [],
createSeriesId: true, // If true, a seriesId will be created when this object is saved.
// The portal document options may specify section to hide
edit: false, // Set to true if this model is being used in a portal editor view
hideMetrics: null,
hideData: null,
hideMembers: null,
hideMap: null,
// List of section labels indicating the order in which to display the
// sections. Labels must exactly match the labels set on sections, or
// the values set on the metricsLabel, dataLabel, and membersLabel
// options.
pageOrder: null,
// Options for the custom section labels NOTE: This are not fully
// supported yet.
metricsLabel: "Metrics",
dataLabel: "Data",
membersLabel: "Members",
// Map options, as specified in the portal document options
mapZoomLevel: 3,
mapCenterLatitude: null,
mapCenterLongitude: null,
mapShapeHue: 200,
// The MapModel
mapModel: gmaps ? new MapModel() : null,
optionNames: [
"primaryColor",
"secondaryColor",
"accentColor",
"mapZoomLevel",
"mapCenterLatitude",
"mapCenterLongitude",
"mapShapeHue",
"hideData",
"hideMetrics",
"hideMembers",
"pageOrder",
"layout",
"theme",
],
// Portal view colors, as specified in the portal document options
primaryColor:
MetacatUI.appModel.get("portalDefaults").primaryColor || "#006699",
secondaryColor:
MetacatUI.appModel.get("portalDefaults").secondaryColor ||
"#009299",
accentColor:
MetacatUI.appModel.get("portalDefaults").accentColor || "#f89406",
primaryColorRGB: null,
secondaryColorRGB: null,
accentColorRGB: null,
primaryColorTransparent:
MetacatUI.appModel.get("portalDefaults").primaryColorTransparent ||
"rgba(0, 102, 153, .7)",
secondaryColorTransparent:
MetacatUI.appModel.get("portalDefaults")
.secondaryColorTransparent || "rgba(0, 146, 153, .7)",
accentColorTransparent:
MetacatUI.appModel.get("portalDefaults").accentColorTransparent ||
"rgba(248, 148, 6, .7)",
theme: null,
layout: null,
});
},
/**
* The default text to use for a new section label added by the user
* @type {string}
*/
newSectionLabel: "Untitled",
/**
* Overrides the default Backbone.Model.initialize() function to provide
* some custom initialize options
* @param {object} attrs - Initial model attributes.
*/
initialize(attrs) {
// Call the super class initialize function
CollectionModel.prototype.initialize.call(this, attrs);
// Generate transparent colours from the primary, secondary, and accent
// colors TODO
if (attrs.isNew) {
this.set("synced", true);
// Create an isPartOf filter for this new Portal
this.addIsPartOfFilter();
const model = this;
// Insert new sections if any are set in the appModel
const portalDefaults = MetacatUI.appModel.get("portalDefaults");
const defaultSections = portalDefaults ? portalDefaults.sections : [];
if (
defaultSections &&
defaultSections.length &&
Array.isArray(defaultSections)
) {
defaultSections.forEach(function addDefaultSection(section) {
// If there is at least one section default set...
if (section.title || section.label) {
const newDefaultSection = new PortalSectionModel({
title: section.title || "",
label: section.label || this.newSectionLabel,
// Set a default image on new markdown sections
image: model.getRandomSectionImage(),
portalModel: model,
});
model.addSection(newDefaultSection);
}
});
}
}
// Cache this model for later use
this.cachePortal();
},
/**
* getRandomSectionImage - Using the list of image identifiers set in the
* app config, select an image to use for a portal section. The function
* will not return the same image until all the images have been returned
* at least once. If an image would return a 404 error, it is skipped. If
* all images give 404s, an empty string is returned.
* @returns {(PortalImage|string|undefined)} A portal image model, an
* empty string when configured images are unavailable, or undefined when
* no images are configured.
*/
getRandomSectionImage() {
// This variable will hold the section image to return, if any
let newSectionImage = "";
// The default portal values set in the config
const portalDefaults = MetacatUI.appModel.get("portalDefaults");
// Check if default images are set on the model already
let defaultImageIds = this.get("defaultSectionImageIds");
// Keep track of where we are in the list of default images, so there's
// not too much repetition
let runningNumber = this.get("defaultImageRunningNumber") || 0;
// If none are set, get the configured default image IDs, shuffle them,
// and set them on the model.
if (!defaultImageIds || !defaultImageIds.length) {
// Get the list of default section image IDs from the appModel
defaultImageIds = portalDefaults
? portalDefaults.sectionImageIdentifiers
: false;
// If some are configured...
if (defaultImageIds && defaultImageIds.length) {
// ...Shuffle the images...
for (
let imageIndex = defaultImageIds.length - 1;
imageIndex > 0;
imageIndex -= 1
) {
const shuffledIndex = Math.floor(
Math.random() * (imageIndex + 1),
);
[defaultImageIds[imageIndex], defaultImageIds[shuffledIndex]] = [
defaultImageIds[shuffledIndex],
defaultImageIds[imageIndex],
];
}
// ... and save the shuffled list to the portal model
this.set("defaultSectionImageIds", defaultImageIds);
}
}
// Can't get a random image if none are configured
if (!defaultImageIds || !defaultImageIds.length) {
return undefined;
}
// Select one of the image IDs
if (defaultImageIds && defaultImageIds.length > 0) {
if (runningNumber >= defaultImageIds.length) {
runningNumber = 0;
}
// Go through the shuffled array of image IDs in order
for (
let imageIndex = runningNumber;
imageIndex < defaultImageIds.length;
imageIndex += 1
) {
// Skip images that have already returned 404 errors
if (defaultImageIds[imageIndex] !== "NOT FOUND") {
// Section images are PortalImage models
newSectionImage = new PortalImage({
identifier: defaultImageIds[imageIndex],
portalModel: this.get("portalModel"),
});
// Skip adding an image if it doesn't exist given the identifer
// and baseUrl found in the image model
if (newSectionImage.imageExists()) {
runningNumber = imageIndex;
break;
}
// If the image doesn't exist, mark it so we don't have to check
// again next time
defaultImageIds[imageIndex] = "NOT FOUND";
newSectionImage = "";
}
runningNumber = imageIndex + 1;
}
}
this.set("defaultImageRunningNumber", runningNumber + 1);
this.set("defaultSectionImageIds", defaultImageIds);
return newSectionImage;
},
/**
* Returns the portal URL
* @returns {string} The portal URL
*/
url() {
// Start the base URL string use the resolve service if there is no
// object service url (e.g. in DataONE theme)
let urlBase =
MetacatUI.appModel.get("objectServiceUrl") ||
MetacatUI.appModel.get("resolveServiceUrl");
// Get the active alternative repository, if one is configured
const activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
if (activeAltRepo) {
urlBase = activeAltRepo.objectServiceUrl;
}
// If this object is being updated, use the old pid in the URL
if (!this.isNew() && this.get("oldPid")) {
return urlBase + encodeURIComponent(this.get("oldPid"));
}
// If this object is new, use the new pid in the URL
return (
urlBase + encodeURIComponent(this.get("seriesId") || this.get("id"))
);
},
/**
* Overrides the default Backbone.Model.fetch() function to provide some
* custom fetch options
* @param {object} [options] - Options for this fetch.
* @param {boolean} [options.objectOnly] - If true, only the object
* will be retrieved and not the system metadata
* @param {boolean} [options.systemMetadataOnly] - If true, only the
* system metadata will be retrieved
* @returns {(jqXHR|undefined)} The fetch request, or undefined when the
* request is deferred or only system metadata is fetched.
*/
fetch(options) {
const fetchOptions = options ? { ...options } : {};
// If the seriesId has not been found yet, get it from Solr
if (!this.get("id") && !this.get("seriesId") && this.get("label")) {
this.once("change:seriesId", function fetchWhenSeriesIdChanges() {
this.fetch(fetchOptions);
});
this.once(
"latestVersionFound",
function fetchWhenLatestVersionFound() {
this.fetch(fetchOptions);
},
);
// Get the series ID of this object
this.getSeriesIdByLabel();
return undefined;
}
// If we found the latest version in this pid version chain,
if (this.get("id") && this.get("latestVersion")) {
// Set it as the id of this model
this.set("id", this.get("latestVersion"));
// Stop listening to the change of seriesId and the latest version
// found
this.stopListening("change:seriesId", this.fetch);
this.stopListening("latestVersionFound", this.fetch);
}
// If this MetacatUI instance is pointing to a CN, use the origin MN to
// fetch the Portal, if available as an alt repo.
if (MetacatUI.appModel.get("isCN") && this.get("datasource")) {
// Check if the origin MN (datasource) is an alt repo option
const altRepo = MetacatUI.appModel
.get("alternateRepositories")
.find(
(repository) => repository.identifier === this.get("datasource"),
);
if (altRepo) {
// Set the origin MN (datasource) as the active alt repo
MetacatUI.appModel.set(
"activeAlternateRepositoryId",
this.get("datasource"),
);
}
}
// Fetch the system metadata
if (!fetchOptions.objectOnly || fetchOptions.systemMetadataOnly) {
this.fetchSystemMetadata();
if (fetchOptions.systemMetadataOnly) {
return undefined;
}
}
// Record whether this fetch uses authentication for private portals.
this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));
const requestSettings = {
dataType: "xml",
success: (response) => {
let attributes;
try {
attributes = this.parse(response);
} catch (error) {
this.trigger("error", this, error.message);
return;
}
this.set(attributes);
this.trigger("sync", this, response, requestSettings);
},
error: (response) => {
this.trigger("error", this, response);
if (response?.status === 404) {
this.trigger("notFound");
}
},
...MetacatUI.appUserModel.createAjaxSettings(),
};
// Parse before setting attributes or emitting sync so a failed load
// cannot open the editor with incomplete data.
return this.sync("read", this, requestSettings);
},
/**
* Get the portal seriesId by searching for the portal by its label in
* Solr
*/
getSeriesIdByLabel() {
const model = this;
const label = this.get("label");
if (!label) return;
// Save a boolean flag for whether or not this fetch was done with user
// authentication.
this.set("fetchedWithAuth", MetacatUI.appUserModel.get("loggedIn"));
// Build candidate query service URLs to try
let candidates = [];
try {
const possibleAuthMNs = this.get("possibleAuthMNs") || [];
if (
MetacatUI.appModel.get("alternateRepositories").length &&
possibleAuthMNs.length
) {
candidates = possibleAuthMNs
.map((mn) => mn && mn.queryServiceUrl)
.filter(Boolean);
} else {
candidates = [MetacatUI.appModel.get("queryServiceUrl")];
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(
"Error determining queryServiceUrl candidates; falling back to AppModel setting.",
e,
);
}
if (!candidates.length && MetacatUI.appModel.get("queryServiceUrl")) {
candidates = [MetacatUI.appModel.get("queryServiceUrl")];
}
candidates = [...new Set(candidates.filter(Boolean))];
if (!candidates.length) {
this.trigger("notFound");
return;
}
const tryIndex = function tryQueryServiceUrl(i) {
if (i >= candidates.length) {
model.trigger("notFound");
return;
}
QueryService.queryWithFetch({
q: `label:"${label}" OR seriesId:"${label}"`,
fields: ["seriesId", "id", "label", "datasource"],
sort: "dateUploaded asc",
rows: 1,
urlBase: candidates[i],
useAuth: true,
})
.then((response) => QueryService.parseResponse(response))
.then((docs) => {
if (!docs.length) {
tryIndex(i + 1);
return;
}
const doc = docs[0];
model.set("label", doc.label);
model.set("datasource", doc.datasource);
if (doc.seriesId) {
model.set("seriesId", doc.seriesId);
} else if (doc.id) {
model.set("id", doc.id);
model.findLatestVersion(doc.id);
} else {
model.trigger("notFound");
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error(e);
tryIndex(i + 1);
});
};
tryIndex(0);
},
/**
* This function has been renamed `getSeriesIdByLabel` and may be removed
* in future releases.
* @deprecated This function has been renamed `getSeriesIdByLabel` and may
* be removed in future releases.
* @see PortalModel#getSeriesIdByLabel
*/
getSeriesIdByName() {
this.getSeriesIdByLabel();
},
/**
* Overrides the default Backbone.Model.parse() function to parse the
* custom portal XML document
* @param {XMLDocument} response - The XMLDocument returned from the
* fetch() AJAX call
* @returns {object} The parsed portal attributes, to be set
* directly on the model.
*/
parse(response) {
// Start the empty JSON object
let modelJSON = {};
const modelRef = this;
let portalNode;
// Iterate over each root XML node to find the portal node
$(response)
.children()
.each((i, el) => {
if (el.tagName.indexOf("portal") > -1) {
portalNode = el;
return false;
}
return true;
});
// If a portal XML node wasn't found, return an empty JSON object
if (typeof portalNode === "undefined" || !portalNode) {
return {};
}
// Parse the collection elements
modelJSON = this.parseCollectionXML(portalNode);
// Save the xml for serialize
modelJSON.objectXML = response;
// Parse the portal logo
const portLogo = $(portalNode).children("logo")[0];
if (portLogo) {
const portImageModel = new PortalImage({
objectDOM: portLogo,
portalModel: this,
});
portImageModel.set(portImageModel.parse());
modelJSON.logo = portImageModel;
}
// Parse acknowledgement logos into urls
const logos = $(portalNode).children("acknowledgmentsLogo");
modelJSON.acknowledgmentsLogos = [];
_.each(
logos,
function parseAcknowledgmentsLogo(logo) {
if (!logo) return;
const imageModel = new PortalImage({
objectDOM: logo,
portalModel: this,
});
imageModel.set(imageModel.parse());
if (imageModel.get("imageURL")) {
modelJSON.acknowledgmentsLogos.push(imageModel);
}
},
this,
);
// Parse the literature cited This will only work for bibtex at the
// moment
const bibtex = $(portalNode)
.children("literatureCited")
.children("bibtex");
if (bibtex.length > 0) {
modelJSON.literatureCited = this.parseTextNode(
portalNode,
"literatureCited",
);
}
// Parse the portal content sections
modelJSON.sections = [];
$(portalNode)
.children("section")
.each((i, section) => {
// Get the section type, if there is one
const sectionTypeOption = PortalOption.findDirectChild(
section,
"sectionType",
);
const sectionType = sectionTypeOption
? PortalOption.fromElement(sectionTypeOption).optionValue[0]
: "";
if (sectionType === "visualization") {
// Create a new PortalVizSectionModel
modelJSON.sections.push(
new PortalVizSectionModel({
objectDOM: section,
literatureCited: modelJSON.literatureCited,
}),
);
} else {
// Create a new PortalSectionModel
modelJSON.sections.push(
new PortalSectionModel({
objectDOM: section,
literatureCited: modelJSON.literatureCited,
portalModel: modelRef,
}),
);
}
// Parse the PortalSectionModel
modelJSON.sections[i].set(modelJSON.sections[i].parse(section));
});
// Parse the EMLText elements
modelJSON.acknowledgments = this.parseEMLTextNode(
portalNode,
"acknowledgments",
);
// Parse the awards
modelJSON.awards = [];
const { parseTextNode } = this;
$(portalNode)
.children("award")
.each((_awardIndex, award) => {
const awardParsed = {};
$(award)
.children()
.each((_attributeIndex, awardAttribute) => {
if (awardAttribute.nodeName !== "funderLogo") {
// parse the text nodes
awardParsed[awardAttribute.nodeName] = parseTextNode(
award,
awardAttribute.nodeName,
);
} else {
// parse funderLogo which is type ImageType
const imageModel = new PortalImage({
objectDOM: awardAttribute,
});
imageModel.set(imageModel.parse());
awardParsed[awardAttribute.nodeName] = imageModel;
}
});
modelJSON.awards.push(awardParsed);
});
// Parse the associatedParties
modelJSON.associatedParties = [];
$(portalNode)
.children("associatedParty")
.each((i, associatedParty) => {
modelJSON.associatedParties.push(
new EMLParty({
objectDOM: associatedParty,
}),
);
});
// Parse only direct root options; section options are nested in sections.
Array.from(portalNode.children)
.filter((child) => child.localName === "option")
.forEach((element) => {
const option = PortalOption.fromElement(element);
const { optionName } = option;
let optionValue = option.optionValue[0];
if (optionValue === "true") {
optionValue = true;
} else if (optionValue === "false") {
optionValue = false;
}
// Convert the comma separated list of pages into an array
if (
optionName === "pageOrder" &&
optionValue &&
optionValue.length
) {
optionValue = optionValue.split(",");
}
if (!_.has(modelJSON, optionName)) {
modelJSON[optionName] = optionValue;
}
});
// Convert all the hex colors to rgb
if (modelJSON.primaryColor) {
modelJSON.primaryColorRGB = this.hexToRGB(modelJSON.primaryColor);
modelJSON.primaryColorTransparent = `rgba(${modelJSON.primaryColorRGB.r},${modelJSON.primaryColorRGB.g},${modelJSON.primaryColorRGB.b}, .7)`;
}
if (modelJSON.secondaryColor) {
modelJSON.secondaryColorRGB = this.hexToRGB(modelJSON.secondaryColor);
modelJSON.secondaryColorTransparent = `rgba(${modelJSON.secondaryColorRGB.r},${modelJSON.secondaryColorRGB.g},${modelJSON.secondaryColorRGB.b}, .5)`;
}
if (modelJSON.accentColor) {
modelJSON.accentColorRGB = this.hexToRGB(modelJSON.accentColor);
modelJSON.accentColorTransparent = `rgba(${modelJSON.accentColorRGB.r},${modelJSON.accentColorRGB.g},${modelJSON.accentColorRGB.b}, .5)`;
}
if (gmaps) {
// Create a MapModel with all the map options
modelJSON.mapModel = new MapModel();
const mapOptions = modelJSON.mapModel.get("mapOptions");
if (modelJSON.mapZoomLevel) {
mapOptions.zoom = parseInt(modelJSON.mapZoomLevel, 10);
mapOptions.minZoom = parseInt(modelJSON.mapZoomLevel, 10);
}
if (
(modelJSON.mapCenterLatitude ||
modelJSON.mapCenterLatitude === 0) &&
(modelJSON.mapCenterLongitude || modelJSON.mapCenterLongitude === 0)
) {
mapOptions.center = modelJSON.mapModel.createLatLng(
modelJSON.mapCenterLatitude,
modelJSON.mapCenterLongitude,
);
}
if (modelJSON.mapShapeHue) {
modelJSON.mapModel.set("tileHue", modelJSON.mapShapeHue);
}
}
// Parse the UIFilterGroups
modelJSON.filterGroups = [];
const allFilters = modelJSON.searchModel.get("filters");
$(portalNode)
.children("filterGroup")
.each((i, filterGroup) => {
// Create a FilterGroup model
const filterGroupModel = new FilterGroup({
objectDOM: filterGroup,
isUIFilterType: true,
});
modelJSON.filterGroups.push(filterGroupModel);
// Add the Filters from this FilterGroup to the portal's Search
// model, unless this portal model is being edited. Then we only
// want the definition filters to be included in the search model.
if (!modelRef.get("edit")) {
allFilters.add(filterGroupModel.get("filters").models);
}
});
return modelJSON;
},
/**
* Parses the XML nodes that are of type EMLText
* @param {Element} parentNode - The XML Element that contains all the
* EMLText nodes
* @param {string} nodeName - The name of the XML node to parse
* @param {boolean} isMultiple - If true, parses the nodes into an array
* @returns {(EMLText|EMLText[]|null)} The parsed EMLText model or models,
* or null when a single matching node is not found
*/
parseEMLTextNode(parentNode, nodeName, isMultiple) {
const node = $(parentNode).children(nodeName);
// If no matching nodes were found, return falsey values
if (!node || !node.length) {
// Return an empty array if the isMultiple flag is true
if (isMultiple) return [];
// Return null if the isMultiple flag is false
return null;
}
// If exactly one node is found and we are only expecting one, return
// the text content
if (node.length === 1 && !isMultiple) {
return new EMLText({
objectDOM: node[0],
});
}
// If more than one node is found, parse into an array
return _.map(
node,
(emlNode) =>
new EMLText({
objectDOM: emlNode,
}),
);
},
/**
* Sets the fileName attribute on this model using the portal label
* @override
*/
setMissingFileName() {
let fileName = this.get("label");
if (!fileName) {
fileName = "portal.xml";
} else {
fileName = `${fileName.replace(/[^a-zA-Z0-9]/g, "_")}.xml`;
}
this.set("fileName", fileName);
},
/**
* @typedef {object} PortalModel#rgb - An RGB color value
* @property {number} r - A value between 0 and 255 defining the intensity
* of red
* @property {number} g - A value between 0 and 255 defining the intensity
* of green
* @property {number} b - A value between 0 and 255 defining the intensity
* of blue
*/
/**
* Converts hex color values to RGB
* @param {string} hex - a color in hexadecimal format
* @returns {(PortalModel#rgb|null)} The RGB color, or null if the hex
* value is invalid
*/
hexToRGB(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
},
/**
* Finds the node in the given portal XML document afterwhich the given
* node type should be inserted
* @param {Element} portalNode - The portal element of an XML document
* @param {string} nodeName - The name of the node to be inserted into xml
* @returns {(jQuery|boolean)} A jQuery object indicating a position, or
* false when nodeName is not in the portal
* schema
*/
getXMLPosition(portalNode, nodeName) {
const nodeOrder = [
"label",
"name",
"description",
"definition",
"logo",
"section",
"associatedParty",
"acknowledgments",
"acknowledgmentsLogo",
"award",
"literatureCited",
"filterGroup",
"option",
];
const position = nodeOrder.indexOf(nodeName);
// First check that nodeName is in the list of nodes
if (position === -1) {
return false;
}
// If there's already an occurence of nodeName...
if ($(portalNode).children(nodeName).length > 0) {
// ...insert it after the last occurence
return $(portalNode).children(nodeName).last();
}
// Go through each node in the node list and find the position after
// which this node will be inserted
for (let i = position - 1; i >= 0; i -= 1) {
if ($(portalNode).children(nodeOrder[i]).length) {
return $(portalNode).children(nodeOrder[i]).last();
}
}
return false;
},
/**
* Retrieves the model attributes and serializes into portal XML, to
* produce the new or modified portal document.
* @returns {(string|undefined)} The portal XML, or undefined when
* serialization fails.
*/
serialize() {
try {
// So we can call getXMLPosition() from within if{}
const model = this;
let xmlDoc;
let portalNode;
let xmlString;
xmlDoc = this.get("objectXML");
// Check if there is a portal doc already
if (xmlDoc == null) {
// If not create one
xmlDoc = this.createXML();
} else {
// If yes, clone it
xmlDoc = xmlDoc.cloneNode(true);
}
// Iterate over each root XML node to find the portal node
$(xmlDoc)
.children()
.each((i, el) => {
if (el.tagName.indexOf("portal") > -1) {
portalNode = el;
}
});
// Serialize the collection elements ("name", "label", "description",
// "definition")
portalNode = this.updateCollectionDOM(portalNode);
xmlDoc = portalNode.getRootNode();
const $portalNode = $(portalNode);
// Set formatID
this.set(
"formatId",
MetacatUI.appModel.get("portalEditorSerializationFormat") ||
"https://purl.dataone.org/portals-1.1.0",
);
/* ==== Serialize portal logo ==== */
// Remove node if it exists already
$(xmlDoc).find("logo").remove();
// Get new values
const logo = this.get("logo");
// Don't serialize falsey values or empty logos
if (logo && logo.get("identifier")) {
// Make new node
const logoSerialized = logo.updateDOM("logo");
// Add the logo node to the XMLDocument
xmlDoc.adoptNode(logoSerialized);
// Insert new node at correct position
const insertAfter = this.getXMLPosition(portalNode, "logo");
if (insertAfter) {
insertAfter.after(logoSerialized);
} else {
portalNode.appendChild(logoSerialized);
}
}
/* ==== Serialize acknowledgment logos ==== */
// Remove element if it exists already
$(xmlDoc).find("acknowledgmentsLogo").remove();
const acknowledgmentsLogos = this.get("acknowledgmentsLogos");
// Don't serialize falsey values
if (acknowledgmentsLogos) {
_.each(acknowledgmentsLogos, (imageModel) => {
// Don't serialize empty imageModels
if (
imageModel.get("identifier") ||
imageModel.get("label") ||
imageModel.get("associatedURL")
) {
const ackLogosSerialized = imageModel.updateDOM();
// Add the logo node to the XMLDocument
xmlDoc.adoptNode(ackLogosSerialized);
// Insert new node at correct position
const insertAfter = model.getXMLPosition(
portalNode,
"acknowledgmentsLogo",
);
if (insertAfter) {
insertAfter.after(ackLogosSerialized);
} else {
portalNode.appendChild(ackLogosSerialized);
}
}
});
}
/* ==== Serialize literature cited ==== */
// Assumes the value of literatureCited is a block of bibtex text
// Remove node if it exists already
$(xmlDoc).find("literatureCited").remove();
// Get new values
let litCit = this.get("literatureCited");
// Don't serialize falsey values
if (litCit.length) {
// If there's only one element in litCited, it will be a string turn
// it into an array so that we can use _.each
if (typeof litCit === "string") {
litCit = [litCit];
}
// Make new <literatureCited> element
const litCitSerialized = xmlDoc.createElement("literatureCited");
_.each(litCit, (bibtex) => {
// Wrap in literature cited in cdata tags
const cdataLitCit = xmlDoc.createCDATASection(bibtex);
const bibtexSerialized = xmlDoc.createElement("bibtex");
// wrap in CDATA tags so that bibtex characters aren't escaped
bibtexSerialized.appendChild(cdataLitCit);
// <bibxtex> is a subelement of <literatureCited>
litCitSerialized.appendChild(bibtexSerialized);
});
// Insert new element at correct position
const insertAfter = this.getXMLPosition(
portalNode,
"literatureCited",
);
if (insertAfter) {
insertAfter.after(litCitSerialized);
} else {
portalNode.appendChild(litCitSerialized);
}
}
/* ==== Serialize portal content sections ==== */
// Remove node if it exists already
$portalNode.children("section").remove();
const sections = this.get("sections");
// Don't serialize falsey values
if (sections) {
_.each(
sections,
function serializeSection(sectionModel) {
// Don't serialize sections with default values
if (!this.sectionIsDefault(sectionModel)) {
const sectionSerialized = sectionModel.updateDOM();
// If there was an error serializing this section, or if
// nothing was returned, don't do anythiing further
if (!sectionSerialized) {
return;
}
// Add the section node to the XMLDocument
xmlDoc.adoptNode(sectionSerialized);
// Remove sections entirely if the content is blank
const newMD = $(sectionSerialized).find("markdown")[0];
if (!newMD || newMD.textContent === "") {
$(sectionSerialized).find("markdown").remove();
}
// Remove the <content> element if it's empty. This will
// trigger a validation error, prompting user to enter
// content.
if ($(sectionSerialized).find("content").is(":empty")) {
$(sectionSerialized).find("content").remove();
}
// Insert new node at correct position
const insertAfter = model.getXMLPosition(
portalNode,
"section",
);
if (insertAfter) {
insertAfter.after(sectionSerialized);
} else {
portalNode.appendChild(sectionSerialized);
}
}
},
this,
);
}
/* ==== Serialize the EMLText elements ("acknowledgments") ==== */
const textFields = ["acknowledgments"];
_.each(
textFields,
function serializeTextField(field) {
const fieldName = field;
// Get the EMLText model
const emlTextModels = Array.isArray(this.get(field))
? this.get(field)
: [this.get(field)];
if (!emlTextModels.length) return;
// Get the node from the XML doc
const nodes = $portalNode.children(fieldName);
// Update the DOMs for each model
_.each(
emlTextModels,
function serializeEMLText(thisTextModel, i) {
// Don't serialize falsey values
if (!thisTextModel) return;
let node;
// Get the existing node or create a new one
if (nodes.length < i + 1) {
node = xmlDoc.createElement(fieldName);
this.getXMLPosition(portalNode, fieldName).after(node);
} else {
node = nodes[i];
}
const textModelSerialized = thisTextModel.updateDOM();
// If the text model wasn't serialized correctly or resulted
// in nothing
if (
typeof textModelSerialized === "undefined" ||
!textModelSerialized
) {
// Remove the existing node
$(node).remove();
} else {
xmlDoc.adoptNode(textModelSerialized);
$(node).replaceWith(textModelSerialized);
}
},
this,
);
// Remove the extra nodes
this.removeExtraNodes(nodes, emlTextModels);
},
this,
);
/* ==== Serialize awards ==== */
// Remove award node if it exists already
$portalNode.children("award").remove();
// Get new values
const awards = this.get("awards");
// Don't serialize falsey values
if (awards && awards.length > 0) {
_.each(awards, (award) => {
// Make new node
const awardSerialized = xmlDoc.createElement("award");
// create the <award> subnodes
_.map(award, (value, nodeName) => {
// serialize the simple text nodes
if (nodeName !== "funderLogo") {
// Don't serialize falsey values
if (value) {
// Make new sub-nodes
const awardSubnodeSerialized =
xmlDoc.createElement(nodeName);
$(awardSubnodeSerialized).text(value);
$(awardSerialized).append(awardSubnodeSerialized);
}
} else {
// serialize "funderLogo" which is ImageType
const funderLogoSerialized = value.updateDOM();
xmlDoc.adoptNode(funderLogoSerialized);
$(awardSerialized).append(funderLogoSerialized);
}
});
// Insert new node at correct position
const insertAfter = model.getXMLPosition(portalNode, "award");
if (insertAfter) {
insertAfter.after(awardSerialized);
} else {
portalNode.appendChild(awardSerialized);
}
});
}
/* ==== Serialize associatedParties ==== */
// Remove element if it exists already
$portalNode.children("associatedParty").remove();
// Get new values
const parties = this.get("associatedParties");
// Don't serialize falsey values
if (parties) {
// Serialize each associatedParty
_.each(parties, (party) => {
// Update the DOM of the EMLParty
const partyEl = party.updateDOM();
const partyDoc = $.parseXML(party.formatXML($(partyEl)[0]));
// Make sure we don't insert empty EMLParty nodes into the EML
if (partyDoc.childNodes.length) {
// Save a reference to the associated party element in the
// NodeList
const assocPartyEl = partyDoc.childNodes[0];
// Add the associated part element to the portal XML doc
xmlDoc.adoptNode(assocPartyEl);
// Get the last node of this type to insert after
let insertAfter = $portalNode
.children("associatedParty")
.last();
// If there isn't a node found, find the EML position to insert
// after
if (!insertAfter.length) {
insertAfter = model.getXMLPosition(
portalNode,
"associatedParty",
);
}
// Insert the party DOM at the insert position
if (insertAfter && insertAfter.length) {
insertAfter.after(assocPartyEl);
} else {
portalNode.appendChild(assocPartyEl);
}
}
});
}
/* ==== Serialize options (including map options) ==== */
// Only the standard option names are written from model attributes.
// Existing custom options remain in the cloned XML, but new or edited
// custom option attributes are not saved until their names are tracked.
// TODO: Track custom option names if editing them is supported.
_.each(this.get("optionNames"), (optName) => {
const optValue = model.get(optName);
const matchingOption = PortalOption.findDirectChild(
portalNode,
optName,
);
if (
(optValue || optValue === 0 || optValue === false) &&
// XML option values are strings, while model defaults can be numbers.
// eslint-disable-next-line eqeqeq
optValue != model.defaults()[optName]
) {
const option = matchingOption
? PortalOption.fromElement(matchingOption)
: new PortalOption({
optionName: optName,
optionValue: [String(optValue)],
});
if (matchingOption) option.optionValue[0] = String(optValue);
const optionElement = option.toElement(xmlDoc);
if (matchingOption) {
matchingOption.replaceWith(optionElement);
} else {
const insertAfter = model.getXMLPosition(portalNode, "option");
if (insertAfter) {
insertAfter.after(optionElement);
} else {
portalNode.appendChild(optionElement);
}
}
} else if (matchingOption) {
matchingOption.remove();
}
});
/* ==== Serialize UI FilterGroups (aka custom search filters) ==== */
// Get new filter group values
const filterGroups = this.get("filterGroups");
// Remove filter groups in the current objectDOM that are at the
// portal level. (don't use .find("filterGroup") as that would remove
// filterGroups that are nested in the definition
$portalNode.children("filterGroup").remove();
// Make a new node for each filter group in the model
_.each(filterGroups, (filterGroup) => {
const filterGroupSerialized = filterGroup.updateDOM();
if (filterGroupSerialized) {
// Add the new element to the XMLDocument
xmlDoc.adoptNode(filterGroupSerialized);
// Insert new node at correct position
const insertAfter = model.getXMLPosition(
portalNode,
"filterGroup",
);
if (insertAfter) {
insertAfter.after(filterGroupSerialized);
} else {
portalNode.appendChild(filterGroupSerialized);
}
}
});
/* ==== Remove duplicates ==== */
// Do a final check to make sure there are no duplicate ids in the XML
const elementsWithIDs = $(xmlDoc).find("[id]");
// Get an array of all the ids in this EML doc
const allIDs = _.map(elementsWithIDs, (el) => $(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
const uniqueIDs = new Set(allIDs);
// If the unique array is shorter than the array of all ids, then
// there is a duplicate somewhere
if (uniqueIDs.size < allIDs.length) {
// For each element in the EML that has an id,
_.each(elementsWithIDs, (el) => {
// Get the id for this element
const id = $(el).attr("id");
// If there is more than one element in the EML with this id,
if ($(xmlDoc).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", ValueUtilities.makeUUID({ sep: "-" }));
}
});
}
}
// Convert xml to xmlString and return xmlString
xmlString = new XMLSerializer().serializeToString(xmlDoc);
// If there isn't an XML declaration, add one
if (xmlString.indexOf("<?xml") === -1) {
xmlString = `<?xml version="1.0" encoding="UTF-8"?>${xmlString}`;
}
return xmlString;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Error while serializing the Portal XML document: ", e);
this.set("errorMessage", e.stack);
this.trigger(
"errorSaving",
MetacatUI.appModel.get("portalEditSaveErrorMsg"),
);
return undefined;
}
},
/**
* Checks whether the given sectionModel has been updated by the user, or
* whether all attributes match their default values. For a section's
* markdown, the default value is either an empty string or null. For a
* section's label, the default value is either an empty string or a
* string that begins with the value set to PortalModel.newSectionLabel.
* For all other attributes, the defaults are set in
* PortalSectionModel.defaults.
* @param {PortalSectionModel} sectionModel - The model to check against a
* default model
* @returns {boolean} returns true if the sectionModel matches a default
* model, and false when at least one attribute differs
*/
sectionIsDefault(sectionModel) {
try {
const defaults = sectionModel.defaults();
const currentMarkdown = sectionModel.get("content").get("markdown");
const labelRegex = new RegExp(`^${this.newSectionLabel}`, "i");
// For each attribute, check whether it matches the default
if (
// Check whether markdown matches the content that's auto-filled or
// whether it's empty currentMarkdown === this.markdownExample ||
(currentMarkdown === "" || currentMarkdown == null) &&
sectionModel.get("visualizationType") ===
defaults.visualizationType &&
sectionModel.get("image") === defaults.image &&
sectionModel.get("introduction") === defaults.introduction &&
// Check whether label starts with the default new page name, or
// whether it's empty
(labelRegex.test(sectionModel.get("label")) ||
sectionModel.get("label") === "" ||
sectionModel.get("label") == null) &&
sectionModel.get("literatureCited") === defaults.literatureCited &&
sectionModel.get("title") === defaults.title
) {
// All elements of the section match the default
return true;
}
// At least one attribute of the section has been updated
return false;
} catch (e) {
// If there's a problem with this function for some reason, return
// false so that the section is serialized to avoid losing information
// eslint-disable-next-line no-console
console.log(
`Failed to check whether section model is default. Serializing it anyway. Error message:${e}`,
);
return false;
}
},
/**
* Initialize the object XML for a brand spankin' new portal
* @inheritdoc
*/
createXML() {
const format =
MetacatUI.appModel.get("portalEditorSerializationFormat") ||
"https://purl.dataone.org/portals-1.1.0";
const xmlString = `<por:portal xmlns:por="${format}"></por:portal>`;
const xmlNew = $.parseXML(xmlString);
const portalNode = xmlNew.getElementsByTagName("por:portal")[0];
this.set("ownerDocument", portalNode.ownerDocument);
return xmlNew;
},
/**
* Overrides the default Backbone.Model.validate.function() to check if
* this portal model has all the required values necessary to save to the
* server.
* @param {object} [_attrs] - A literal object of model attributes to
* validate.
* @param {object} [_options] - A literal object of options for this
* validation process
* @returns {(object|undefined)} An object of error messages, or undefined
* when there are no errors.
*/
validate(_attrs, _options) {
try {
let errors = {};
const requiredFields =
MetacatUI.appModel.get("portalEditorRequiredFields") || {};
// Execute the superclass validate() function
const collectionErrors =
CollectionModel.prototype.validate.call(this);
if (
typeof collectionErrors === "object" &&
Object.keys(collectionErrors).length
) {
// Use the errors messages from the CollectionModel for this
// PortalModel
errors = collectionErrors;
}
// ---- Validate the description and name ---- Map the model
// attributes to the user-facing attribute name
const textFields = {
description: "description",
name: "title",
};
// Iterate over each text field
_.each(
Object.keys(textFields),
function validateTextField(field) {
// If this field is required, and it is a string
if (
requiredFields[field] &&
typeof this.get(field) === "string"
) {
// If this is an empty string, set an error message
if (!this.get(field).trim().length) {
errors[field] = `A ${textFields[field]} is required.`;
}
}
// If this field is required, and it's not a string at all, set an
// error message
else if (requiredFields[field]) {
errors[field] = `A ${textFields[field]} is required.`;
}
},
this,
);
// ---Validate the sections--- Iterate over each section model
_.each(
this.get("sections"),
(section) => {
// Validate the section model
const sectionErrors = section.validate();
// If there is at least one error, then add an error to the
// PortalModel error list
if (sectionErrors && Object.keys(sectionErrors).length) {
errors.sections = "At least one section has an error";
}
},
this,
);
// ----Validate the logo----
if (
requiredFields.logo &&
(!this.get("logo") || !this.get("logo").get("identifier"))
) {
errors.logo = "A logo image is required";
} else if (this.get("logo")) {
const logoErrors = this.get("logo").validate();
if (logoErrors && Object.keys(logoErrors).length) {
errors.logo = "A logo image is required";
}
}
// ---Validate the acknowledgmentsLogo---
const nonEmptyAckLogos = this.get("acknowledgmentsLogos").filter(
(portalImage) => !portalImage.isEmpty(),
);
if (requiredFields.acknowledgmentsLogos && !nonEmptyAckLogos.length) {
errors.acknowledgmentsLogos =
"At least one partner logo image is required.";
} else if (nonEmptyAckLogos && nonEmptyAckLogos.length) {
_.each(
nonEmptyAckLogos,
(ackLogo) => {
// Validate the portal image model
const ackLogoErrors = ackLogo.validate();
// If there is at least one error, then add an error to the
// PortalModel error list
if (ackLogoErrors && Object.keys(ackLogoErrors).length) {
errors.acknowledgmentsLogosImages =
"At least one acknowledgment logo has an error";
}
},
this,
);
}
// TODO: Validate these other elements, listed below, as they are
// added to the portal editor
// ---Validate the associatedParties---
// ---Validate the acknowledgments---
// ---Validate the award---
// ---Validate the literatureCited---
// ---Validate the filterGroups---
// Return the errors object
if (Object.keys(errors).length) return errors;
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
return undefined;
},
/**
* Queries the Solr discovery index for other Portal objects with this
* same label. Also, checks for the existing block list for repository
* labels If at least one other Portal has the same label, then it is not
* available.
* @param {string} l - The label to query for
*/
checkLabelAvailability(l) {
// Validate the label set on the model if one isn't given
const label = typeof l === "string" ? l.trim() : this.get("label");
if (typeof label !== "string") {
this.errorValidatingLabel();
return;
}
// Check that we have a node blockList
if (!this.get("checkedNodeLabels")) {
// query CN to fetch the latest node data
this.updateNodeBlockList();
this.listenToOnce(this, "change:checkedNodeLabels", () =>
this.checkLabelAvailability(label),
);
return;
}
// Check existing blockList before making a Solr call (case insensitive)
const blockList = this.get("labelBlockList") || [];
if (blockList.find((b) => b.toLowerCase() === label.toLowerCase())) {
this.labelTaken(label);
return;
}
// Query Solr for this label
this.queryForLabel(label);
},
/** Reports that the current portal label is available */
labelAvailable() {
this.trigger("labelAvailable");
},
/**
* Adds a label to the local block list and reports that it is taken.
* @param {string} label - The unavailable portal label
*/
labelTaken(label) {
const blockList = this.get("labelBlockList");
if (Array.isArray(blockList)) blockList.push(label);
this.trigger("labelTaken");
},
/**
* Reports a failure while checking portal label availability.
* @param {(Error|string)} [e] - The error to log
*/
errorValidatingLabel(e) {
const msg = e || "There was an error validating the portal label.";
// eslint-disable-next-line no-console
console.error(msg);
this.trigger("errorValidatingLabel");
},
/**
* Queries a repository for an existing portal label and reports whether
* it is available.
* @param {string} label - The portal label to query for
* @param {string} [urlBase] - The query service URL
* @param {boolean} [tryAltRepo] - Whether to query an alternate
* repository when the label is not found. Defaults to true.
*/
queryForLabel(label, urlBase, tryAltRepo = true) {
const q = `label:"${label}" AND formatId:"${this.get("formatId")}"`;
const opts = { q, rows: 0, useAuth: true };
if (urlBase) opts.urlBase = urlBase;
QueryService.queryWithFetch(opts)
.then((response) => {
if (response?.response?.numFound > 0) {
this.labelTaken(label);
} else if (
tryAltRepo &&
MetacatUI.appModel.get("alternateRepositories").length
) {
MetacatUI.appModel.setActiveAltRepo();
const activeAltRepo = MetacatUI.appModel.getActiveAltRepo();
if (!activeAltRepo) {
this.labelAvailable();
} else {
this.queryForLabel(label, activeAltRepo.queryServiceUrl, false);
}
} else {
this.labelAvailable();
}
})
.catch((e) => this.errorValidatingLabel(e));
},
/** Queries the CN Solr to retrieve the updated BlockList */
updateNodeBlockList() {
const model = this;
$.ajax({
url: MetacatUI.appModel.get("nodeServiceUrl"),
dataType: "text",
error(_data, _textStatus, _xhr) {
// if there is an error in retrieving the node list; proceed with
// the existing node list to perform the checks
this.set("checkedNodeLabels", "true");
},
success(data, _textStatus, _xhr) {
const xmlResponse = $.parseXML(data) || null;
if (!xmlResponse) return;
// update the node block list on success
model.saveNodeBlockList(xmlResponse);
},
});
},
/**
* Parses the retrieved XML document and saves the node information to the
* BlockList
* @param {XMLDocument} xml - The XMLDocument returned from the fetch()
* AJAX call.
*/
saveNodeBlockList(xml) {
const model = this;
const children = xml.children || xml.childNodes;
// Traverse the XML response to get the MN info
_.each(children, (d1NodeList) => {
const d1NodeListChildren =
d1NodeList.children || d1NodeList.childNodes;
// The first (and only) child should be the d1NodeList
_.each(d1NodeListChildren, (thisNode) => {
// Ignore parts of the XML that is not MN info
if (!thisNode.attributes) return;
// 'node' will be a single node
const node = {};
const nodeProperties = thisNode.children || thisNode.childNodes;
// Grab information about this node from XML nodes
_.each(nodeProperties, (nodeProperty) => {
if (nodeProperty.nodeName === "property")
node[$(nodeProperty).attr("key")] = nodeProperty.textContent;
else node[nodeProperty.nodeName] = nodeProperty.textContent;
// Check if this member node has v2 read capabilities - important
// for the Package service
if (
nodeProperty.nodeName === "services" &&
nodeProperty.childNodes.length
) {
const v2 = $(nodeProperty).find(
"service[name='MNRead'][version='v2'][available='true']",
).length;
node.readv2 = v2;
}
});
// Grab information about this node from XLM attributes
_.each(thisNode.attributes, (attribute) => {
node[attribute.nodeName] = attribute.nodeValue;
});
// Append Node name, node identifier and node short identifier to
// the array. node identifier
if (
Array.isArray(model.get("labelBlockList")) &&
model.get("labelBlockList").indexOf(node.identifier) < 0
) {
model.get("labelBlockList").push(node.identifier);
}
// node name
if (node.CN_node_name) {
node.name = node.CN_node_name;
if (
Array.isArray(model.get("labelBlockList")) &&
model.get("labelBlockList").indexOf(node.name) < 0
) {
model.get("labelBlockList").push(node.name);
}
}
// node short identifier
node.shortIdentifier = node.identifier.substring(
node.identifier.lastIndexOf(":") + 1,
);
if (
Array.isArray(model.get("labelBlockList")) &&
model.get("labelBlockList").indexOf(node.shortIdentifier) < 0
) {
model.get("labelBlockList").push(node.shortIdentifier);
}
});
});
this.set("checkedNodeLabels", "true");
},
/**
* Removes nodes from the XML that do not have an accompanying model (i.e.
* nodes which were probably removed by the user during editing)
* @param {jQuery} nodes - The nodes to potentially remove
* @param {Model[]} models - The model to compare to
*/
removeExtraNodes(nodes, models) {
// Remove the extra nodes
const extraNodes = nodes.length - models.length;
if (extraNodes > 0) {
for (let i = models.length; i < nodes.length; i += 1) {
$(nodes[i]).remove();
}
}
},
/**
* Saves the portal XML document to the server using the DataONE API.
* @returns {(false|undefined)} False when validation fails; otherwise
* undefined.
*/
save() {
// Remove empty filters from the custom portal search filters.
this.get("filterGroups").forEach((filterGroupModel) => {
filterGroupModel.get("filters").removeEmptyFilters();
}, this);
// Ensure empty filters (rule groups) are removed, including from within
// any nested filter groups
this.get("definitionFilters").removeEmptyFilters(true);
// Validate before we try anything else
if (!this.isValid()) {
// Trigger the invalid and cancelSave events
this.trigger("invalid");
this.trigger("cancelSave");
// Don't save the model since it's invalid
return false;
}
// Double-check that the label is available, if it was changed
if (
(this.isNew() || this.get("originalLabel") !== this.get("label")) &&
!this.get("labelDoubleChecked")
) {
// If the label is taken
this.listenToOnce(this, "labelTaken", function handleLabelTaken() {
// Stop listening to the label availability
this.stopListening(this, "labelAvailable");
this.trigger("invalid");
this.trigger("cancelSave");
});
this.listenToOnce(
this,
"labelAvailable",
function handleLabelAvailable() {
this.stopListening(this, "labelTaken");
this.set("labelDoubleChecked", true);
this.save();
},
);
// Check label availability
this.checkLabelAvailability(this.get("label"));
// Don't proceed with the rest of the save
return undefined;
}
this.trigger("valid");
// Check if the checksum has been calculated yet.
if (!this.get("checksum")) {
// Serialize the XML
const xml = this.serialize();
// If there is no xml returned from the serialize() function, then
// there was an error, so don't save.
if (typeof xml === "undefined" || !xml) {
// If no error message is set on the model, trigger an error now. If
// there is an error message already, it means the error has already
// been triggered inside the serialize() function.
if (!this.get("errorMessage")) {
this.trigger(
"errorSaving",
MetacatUI.appModel.get("portalEditSaveErrorMsg"),
);
}
return undefined;
}
const xmlBlob = new Blob([xml], { type: "application/xml" });
// Set the Blob as the upload file
this.set("uploadFile", xmlBlob);
// When it is calculated, restart this function
this.off("checksumCalculated", this.save);
this.on("checksumCalculated", this.save);
// Calculate the checksum for this file
this.calculateChecksum();
// Exit this function until the checksum is done
return undefined;
}
CollectionModel.prototype.save.call(this);
return undefined;
},
/**
* Removes or hides the given section from this Portal
* @param {PortalSectionModel|string} section - Either the
* PortalSectionModel to remove, or the name of the section to remove.
* Some sections in the portals are not tied to PortalSectionModels,
* because they are created from other parts of the Portal document. For
* example, the Data, Metrics, and Members sections.
*/
removeSection(section) {
try {
// If this section is a string, remove it by adding custom options
if (typeof section === "string") {
switch (section.toLowerCase()) {
case "data":
this.set("hideData", true);
break;
case "metrics":
this.set("hideMetrics", true);
break;
case "members":
this.set("hideMembers", true);
break;
default:
break;
}
}
// If this section is a section model, delete it from this Portal
else if (
Object.prototype.isPrototypeOf.call(
PortalSectionModel.prototype,
section,
)
) {
// Copy the sections so setting the updated array triggers a change.
const sectionModels = [...this.get("sections")];
sectionModels.splice(sectionModels.indexOf(section), 1);
this.set({ sections: sectionModels });
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
}
},
/**
* Adds the given section to this Portal
* @param {PortalSectionModel|string} section - Either the
* PortalSectionModel to add, or the name of the section to add. Some
* sections in the portals are not tied to PortalSectionModels, because
* they are created from other parts of the Portal document. For example,
* the Data, Metrics, and Members sections.
* @returns {(PortalSectionModel|null)} The section that was added if it
* was a markdown or cesium section, or null if it was a data, metrics, or
* members section.
*/
addSection(section) {
let addedSection = null;
// If this section is a string, add it by adding custom options
if (typeof section === "string") {
switch (section.toLowerCase()) {
case "data":
this.set("hideData", null);
break;
case "metrics":
this.set("hideMetrics", null);
break;
case "members":
this.set("hideMembers", null);
break;
case "freeform": {
// Add a new, blank markdown section with a default image
const newSection = new PortalSectionModel({
portalModel: this,
// Include a default image if some are configured.
image: this.getRandomSectionImage(),
});
this.pushSectionToSectionsArray(newSection);
addedSection = newSection;
break;
}
case "cesium": {
const newSection = new PortalVizSectionModel({
portalModel: this,
visualizationType: "cesium",
});
this.pushSectionToSectionsArray(newSection);
addedSection = newSection;
break;
}
default:
break;
}
}
// If this section is a section model, add it to this Portal
else if (
Object.prototype.isPrototypeOf.call(
PortalSectionModel.prototype,
section,
)
) {
this.pushSectionToSectionsArray(section);
addedSection = section;
}
return addedSection;
},
/**
* Adds the given section to the sections array on the model and triggers
* an "addSection" event with the new section as an argument. Necessary
* because Backbone doesn't trigger a "change" event when an array is
* updated. Sections should be a collection instead of an array, but this
* is a workaround for now.
* @param {PortalSectionModel} newSection - The section to add to the
* sections array on the model.
* @since 2.41.0
*/
pushSectionToSectionsArray(newSection) {
const sectionModels = [...this.get("sections")];
sectionModels.push(newSection);
this.set("sections", sectionModels);
// Trigger event manually so we can just pass newSection
this.trigger("addSection", newSection);
},
/**
* removePortalImage - remove a PortalImage model from either the logo,
* sections, or acknowledgmentsLogos node of the portal model.
* @param {PortalImage} portalImage - The portal image model to remove
*/
removePortalImage(portalImage) {
try {
// find the portalImage to remove
switch (portalImage.get("nodeName")) {
case "logo":
if (portalImage === this.get("logo")) {
this.set("logo", this.defaults().logo);
}
break;
case "image":
_.each(this.get("sections"), (section, _i) => {
if (portalImage === section.get("image")) {
section.set("image", section.defaults().image);
}
});
break;
case "acknowledgmentsLogo": {
const ackLogos = [...this.get("acknowledgmentsLogos")];
ackLogos.splice(ackLogos.indexOf(portalImage), 1);
this.set({ acknowledgmentsLogos: ackLogos });
break;
}
default:
break;
}
} catch (e) {
// eslint-disable-next-line no-console
console.log(
`Failed to remove a portalImage model, error message: ${e}`,
);
}
},
/** Saves a reference to this Portal on the MetacatUI global object */
cachePortal() {
if (this.get("id")) {
MetacatUI.portals = MetacatUI.portals || {};
MetacatUI.portals[this.get("id")] = this;
}
this.on("change:id", this.cachePortal);
},
/**
* Creates a URL for viewing more information about this object
* @returns {string} The URL for viewing this portal.
*/
createViewURL() {
return `${MetacatUI.root}/${MetacatUI.appModel.get(
"portalTermPlural",
)}/${encodeURIComponent(
this.get("label") || this.get("seriesId") || this.get("id"),
)}`;
},
/**
* Sets attributes on this Portal using the given Member Node data
* @param {object} nodeInfoObject - A literal object taken from the
* NodeModel 'members' array
*/
createNodeAttributes(nodeInfoObject) {
const nodeInfo = nodeInfoObject === undefined ? {} : nodeInfoObject;
// TODO - check for undefined for each of the nodeInfo properties
// Setting basic properties from the node info object
this.set("name", nodeInfo.name);
this.set("logo", nodeInfo.logo);
this.set("description", nodeInfo.description);
// Creating repo specific Filters
const nodeFilterModel = new FilterModel({
fields: ["datasource"],
values: [nodeInfo.identifier],
label: "Datasets for a repository",
matchSubstring: false,
operator: "OR",
});
// adding the filter in the node model
this.get("definitionFilters").add(nodeFilterModel);
// Set up the search model
this.get("searchModel").get("filters").add(nodeFilterModel);
},
/**
* 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
* @returns {(string|undefined)} The cleaned string, or undefined for a
* non-string value.
*/
cleanXMLText(textString) {
if (typeof textString !== "string") return undefined;
let cleanText = textString.trim();
// Check for XML/HTML elements
_.each(cleanText.match(/<\s*[^>]*>/g), (xmlNode) => {
// Encode <, >, and </ substrings
let tagName = xmlNode.replace(/>/g, ">");
tagName = tagName.replace(/</g, "<");
// Replace the xmlNode in the full text string
cleanText = cleanText.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)
const invalidCharsRegEx =
// eslint-disable-next-line no-control-regex -- XML 1.0 valid-character range.
/[^\u0009\u000a\u000d\u0020-\uD7FF\uE000-\uFFFD]/g;
cleanText = cleanText.replace(invalidCharsRegEx, "");
return cleanText;
},
/**
* Reports which portal section is active to every section model.
* @param {PortalSectionModel} [model] - The active section model
*/
reportSectionChange(model) {
this.get("sections").forEach((section) => {
section.reportSectionChange(model === section);
});
},
},
);
return PortalModel;
});