define([
"jquery",
"underscore",
"backbone",
"common/QueryService",
"collections/ObjectFormats",
], ($, _, Backbone, QueryService, ObjectFormats) => {
const FALLBACK_OBJECT_FORMATS = new ObjectFormats();
const getObjectFormats = () =>
globalThis.MetacatUI?.objectFormats || FALLBACK_OBJECT_FORMATS;
const DEFAULT_INFO_FIELDS = [
"abstract",
"id",
"seriesId",
"fileName",
"resourceMap,formatType,formatId",
"obsoletedBy",
"isDocumentedBy",
"documents",
"title",
"origin,keywords",
"attributeName",
"pubDate",
"eastBoundCoord",
"westBoundCoord",
"northBoundCoord",
"southBoundCoord",
"beginDate",
"endDate",
"dateUploaded",
"archived",
"datasource",
"replicaMN",
"isAuthorized",
"isPublic,size",
"read_count_i",
"isService",
"serviceTitle",
"serviceEndpoint",
"serviceOutput",
"serviceDescription",
"serviceType",
"project",
"dateModified",
];
/**
* @class SolrResult
* @classdesc A single result from the Solr search service
* @classcategory Models
* @augments Backbone.Model
*/
const SolrResult = Backbone.Model.extend(
/** @lends SolrResult.prototype */ {
/**
* @property {object} defaults - The default attributes for this model
* This model contains all of the attributes found in the SOLR 'docs'
* field inside of the SOLR response element
*/
defaults: {
abstract: null,
entityName: null,
indexed: true,
archived: false,
origin: "",
keywords: "",
title: "",
eastBoundCoord: "",
westBoundCoord: "",
northBoundCoord: "",
southBoundCoord: "",
attributeName: "",
beginDate: "",
endDate: "",
id: "",
isAuthorized: null,
isAuthorized_read: null,
isAuthorized_write: null,
isAuthorized_changePermission: null,
seriesId: null,
resourceMap: null,
downloads: null,
citations: 0,
selected: false,
formatId: null,
formatType: null,
fileName: null,
datasource: null,
rightsHolder: null,
size: 0,
type: "",
url: null,
obsoletedBy: null,
geohash_9: null,
read_count_i: 0,
reads: 0,
isDocumentedBy: null,
isPublic: null,
isService: false,
serviceDescription: null,
serviceTitle: null,
serviceEndpoint: null,
serviceOutput: null,
notFound: false,
newestVersion: null,
// @type {string} - The system metadata XML as a string
systemMetadata: null,
provSources: [],
provDerivations: [],
// Provenance index fields
prov_generated: null,
prov_generatedByDataONEDN: null,
prov_generatedByExecution: null,
prov_generatedByFoafName: null,
prov_generatedByOrcid: null,
prov_generatedByProgram: null,
prov_generatedByUser: null,
prov_hasDerivations: null,
prov_hasSources: null,
prov_instanceOfClass: null,
prov_used: null,
prov_usedByDataONEDN: null,
prov_usedByExecution: null,
prov_usedByFoafName: null,
prov_usedByOrcid: null,
prov_usedByProgram: null,
prov_usedByUser: null,
prov_wasDerivedFrom: null,
prov_wasExecutedByExecution: null,
prov_wasExecutedByUser: null,
prov_wasGeneratedBy: null,
prov_wasInformedBy: null,
},
/** @inheritdoc */
initialize() {
this.setURL();
this.on("change:id", this.setURL);
this.set("type", this.getType());
this.on("change:read_count_i", function setReads() {
this.set("reads", this.get("read_count_i"));
});
},
/**
* The type of this model
* @type {string}
*/
type: "SolrResult",
/** Toggle the `selected` state of the result */
toggle() {
this.selected = !this.get("selected");
},
/**
* Return the general display type for this object.
* @returns {string} The type of this object, such as "image", "program",
* "metadata", "PDF", "annotation" or "data"
*/
getType() {
return getObjectFormats().getType(this.toJSON());
},
/**
* Get a plain-english version of the specific format ID (for selected
* ids)
* @returns {string} The specific format of this object
*/
getFormat() {
return ObjectFormats.getFriendlyFormat(this.get("formatId"));
},
/**
* Sets the URL for this object based on the id and seriesId
*/
setURL() {
if (MetacatUI.appModel.get("objectServiceUrl"))
this.set(
"url",
MetacatUI.appModel.get("objectServiceUrl") +
encodeURIComponent(this.get("id")),
);
else if (MetacatUI.appModel.get("resolveServiceUrl"))
this.set(
"url",
MetacatUI.appModel.get("resolveServiceUrl") +
encodeURIComponent(this.get("id")),
);
},
/**
* Checks if the pid or sid or given string is a DOI
* @param {string} customString - Optional. An identifier string to check
* instead of the id and seriesId attributes on the model
* @returns {boolean} True if it is a DOI
*/
isDOI(customString) {
return (
MetacatUI.appModel.isDOI(customString) ||
MetacatUI.appModel.isDOI(this.get("id")) ||
MetacatUI.appModel.isDOI(this.get("seriesId"))
);
},
/**
* Checks if the currently-logged-in user is authorized to change
* permissions (or other action if set as parameter) on this doc
* @param {string} [action] - The action (read, write, or
* changePermission) to check if the current user has authorization to
* perform. By default checks for the highest level of permission.
* @returns {boolean|null} True if the user is authorized, false if not,
* or null if the authServiceUrl is not set
*/
checkAuthority(action = "changePermission") {
const authServiceUrl = MetacatUI.appModel.get("authServiceUrl");
if (!authServiceUrl) return false;
const model = this;
const requestSettings = {
url: `${
authServiceUrl + encodeURIComponent(this.get("id"))
}?action=${action}`,
type: "GET",
success(_data, _textStatus, _xhr) {
model.set(`isAuthorized_${action}`, true);
model.set("isAuthorized", true);
model.trigger("change:isAuthorized");
},
error(_xhr, _textStatus, _errorThrown) {
model.set(`isAuthorized_${action}`, false);
model.set("isAuthorized", false);
},
};
$.ajax(
_.extend(
requestSettings,
MetacatUI.appUserModel.createAjaxSettings(),
),
);
return null;
},
/**
* Download this object while sending the user's auth token in the
* request.
* @returns {Promise} A promise that resolves to the response object
* @since 2.32.0
*/
async downloadWithCredentials() {
// Call the new getBlob method and handle the response
return this.fetchDataObjectWithCredentials()
.then((response) => this.downloadFromResponse(response))
.catch((error) => this.handleDownloadError(error));
},
/**
* This method will fetch this object while sending the user's auth token
* in the request. The data can then be downloaded or displayed in the
* browser
* @returns {Promise} A promise that resolves when the data is fetched
* @throws {Error} When the request fails; HTTP errors include `status`
* @since 2.32.0
*/
fetchDataObjectWithCredentials() {
const url = this.get("url");
const token = MetacatUI.appUserModel.get("token") || "";
const method = "GET";
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return fetch(url, { method, headers }).then((response) => {
if (!response.ok) {
const error = new Error(`Failed to fetch: ${response.statusText}`);
error.status = response.status;
throw error;
}
return response;
});
},
/**
* Get the filename from the response headers or default to the model's
* title, id, or "download"
* @param {Response} response - The response object from the fetch request
* @returns {string} The filename to save the file as
* @since 2.32.0
*/
getFileNameFromResponse(response) {
const model = this;
let filename = response.headers.get("Content-Disposition");
if (!filename) {
filename =
model.get("fileName") ||
model.get("title") ||
model.get("id") ||
"download";
} else {
filename = filename
.substring(filename.indexOf("filename=") + 9)
.replace(/"/g, "");
}
filename = filename.trim().replace(/ /g, "_");
return filename;
},
/**
* Download data onto the user's computer from the response object
* @param {Response} response - The response object from the fetch request
* @returns {Response} The response object
* @since 2.32.0
*/
async downloadFromResponse(response) {
const model = this;
const blob = await response.blob();
const filename = this.getFileNameFromResponse(response);
// For IE, we need to use the navigator API
if (navigator && navigator.msSaveOrOpenBlob) {
navigator.msSaveOrOpenBlob(blob, filename);
} else {
// Other browsers can download it via a link
const a = document.createElement("a");
const url = URL.createObjectURL(blob);
a.href = url;
a.download = filename;
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Track this event
model.trigger("downloadComplete");
MetacatUI.analytics?.trackEvent(
"download",
"Download DataONEObject",
model.get("id"),
);
return response;
},
/**
* Handle an error that occurs when downloading the object
* @param {Error} e - The error that occurred
* @since 2.32.0
*/
handleDownloadError(e) {
const model = this;
model.trigger("downloadError", e);
// Track the error
MetacatUI.analytics?.trackException(
`Download DataONEObject error: ${e || ""}`,
model.get("id"),
true,
);
},
/**
* Get the information for this object from the Solr index
* @param {string|string[]} [queryFields] - Optional. A comma-separated
* string of fields to retrieve from the Solr index, or an array. If not
* specified, a default set of fields will be used.
*/
getInfo(queryFields) {
// If there is no seriesId set, then search for pid or sid
const sid = this.get("seriesId") || "";
const pid = this.get("id") || "";
if (!sid && !pid) {
throw new Error(
"Need at least one of seriesId or id to query for info",
);
}
const opts = {
q: QueryService.buildIdQuery(pid, sid),
fields: queryFields || DEFAULT_INFO_FIELDS,
rows: 1000,
archived: true,
};
QueryService.queryWithFetch(opts)
.then((data) => this.getInfoSuccess(data))
.catch((error) => this.handleGetInfoError(error));
},
handleGetInfoError(error) {
// eslint-disable-next-line no-console
console.error(`Error getting info for ${this.get("id")}`, error);
const status = error.status || error.cause?.status;
const message = error.message || error.cause?.statusText || error;
if (status) {
this.set("errorStatus", status);
}
if (message) {
this.set("errorMessage", message);
}
if (status === 404) this.set("notFound", true);
this.set("indexed", false);
this.trigger("getInfoError", status, message);
},
getInfoSuccess(data) {
// If the Solr response was not as expected, trigger and error and exit
if (!data?.response?.docs) {
this.handleGetInfoError({ message: "No data found" });
return;
}
const { docs } = data.response;
if (!docs.length) {
this.set("indexed", false);
// Try getting the system metadata as a backup
this.getSysMeta();
return;
}
if (docs.length === 1) {
docs[0].resourceMap = this.parseResourceMapField(docs[0]);
this.set(docs[0]);
this.trigger("sync");
return;
}
// If we searched by seriesId, then let's find the most recent
// version in the series
if (docs.length > 1) {
// Filter out docs that are obsoleted
const mostRecent = docs.filter((doc) => !doc.obsoletedBy);
// TODO: Once the VersionTracker is merged into MetacatUI, then simply
// use the getLatestVersion method here instead of everything that
// follows.
// If there is only one doc that is not obsoleted (the most recent),
// then set this doc's values on this model
if (mostRecent.length === 1) {
mostRecent[0].resourceMap = this.parseResourceMapField(
mostRecent[0],
);
this.set(mostRecent[0]);
this.trigger("sync");
return;
}
// If there are multiple docs without an obsoletedBy statement,
// then retreive the head of the series via the system metadata
const sysMetaRequestSettings = {
url:
MetacatUI.appModel.get("metaServiceUrl") +
encodeURIComponent(docs[0].seriesId),
type: "GET",
success: (sysMetaData) => {
// Get the identifier node from the system metadata
const seriesHeadID = $(sysMetaData).find("identifier").text();
// Get the doc from the Solr results with that identifier
const seriesHead = _.findWhere(docs, { id: seriesHeadID });
// If there is a doc in the Solr results list that matches
// the series head id
if (seriesHead) {
seriesHead.resourceMap = this.parseResourceMapField(seriesHead);
// Set those values on this model
this.set(seriesHead);
}
// Otherwise, just fall back on the first doc in the list
else if (mostRecent.length) {
mostRecent[0].resourceMap = this.parseResourceMapField(
mostRecent[0],
);
this.set(mostRecent[0]);
} else {
docs[0].resourceMap = this.parseResourceMapField(docs[0]);
this.set(docs[0]);
}
this.trigger("sync");
},
error: (__xhr, _textStatus, _errorThrown) => {
// Fall back on the first doc in the list
if (mostRecent.length) {
this.set(mostRecent[0]);
} else {
this.set(docs[0]);
}
this.trigger("sync");
},
};
$.ajax(
_.extend(
sysMetaRequestSettings,
MetacatUI.appUserModel.createAjaxSettings(),
),
);
}
},
/** Get the citation information for this object from the Solr index */
getCitationInfo() {
this.getInfo(
"id,seriesId,origin,pubDate,dateUploaded,title,datasource,project",
);
},
/** Get the system metadata for this object */
getSysMeta() {
const url =
MetacatUI.appModel.get("metaServiceUrl") +
encodeURIComponent(this.get("id"));
const model = this;
const requestSettings = {
url,
type: "GET",
dataType: "text",
success(data, _response, _xhr) {
if (data && data.length) {
model.set("systemMetadata", data);
}
// Check if this is archvied
const archived = $(data).find("archived").text() === "true";
model.set("archived", archived);
// Get the file size
model.set("size", $(data).find("size").text() || "");
// Get the entity name
model.set("fileName", $(data).find("filename").text() || "");
// Check if this is a metadata doc
const formatId = $(data).find("formatid").text() || "";
model.set("formatId", formatId);
if (getObjectFormats().isMetadata({ formatId })) {
model.set("formatType", "METADATA");
}
// Trigger the sync event so the app knows we found the model info
model.trigger("sync");
},
error(response) {
const errorStatus = response.status;
const errorMessage = response.statusText;
model.set({ errorStatus, errorMessage, notFound: true });
model.trigger("error", errorStatus, errorMessage);
},
};
$.ajax(
_.extend(
requestSettings,
MetacatUI.appUserModel.createAjaxSettings(),
),
);
},
/**
* Transgresses the obsolence chain until it finds the newest version that
* this user is authorized to read
* @param {string} [newest] - The id of the newest version to start with.
* If not supplied, this model's id will be used.
* @param {string} [newer] - The id of a possibly newer version.
*/
findLatestVersion(newest, newer) {
// Make sure we have the /meta service configured
if (!MetacatUI.appModel.get("metaServiceUrl")) return;
let newestVersion = newest;
let possiblyNewer = newer;
// If no pid was supplied, use this model's id
if (!newestVersion) {
newestVersion = this.get("id");
possiblyNewer = this.get("obsoletedBy");
}
// If this isn't obsoleted by anything, then there is no newer version
if (!possiblyNewer) {
this.set("newestVersion", newestVersion);
return;
}
const model = this;
// Get the system metadata for the possibly newer version
const requestSettings = {
url:
MetacatUI.appModel.get("metaServiceUrl") +
encodeURIComponent(possiblyNewer),
type: "GET",
success(data) {
// the response may have an obsoletedBy element
const obsoletedBy = $(data).find("obsoletedBy").text();
// If there is an even newer version, then get it and rerun this
// function
if (obsoletedBy)
model.findLatestVersion(possiblyNewer, obsoletedBy);
// If there isn't a newer version, then this is it
else model.set("newestVersion", possiblyNewer);
},
error(xhr) {
// If this newer version isn't found or accessible, then save the
// last accessible id as the newest version
if (
xhr.status === 401 ||
xhr.status === 404 ||
xhr.status === "401" ||
xhr.status === "404"
) {
model.set("newestVersion", newestVersion);
}
},
};
$.ajax(
_.extend(
requestSettings,
MetacatUI.appUserModel.createAjaxSettings(),
),
);
},
// ================ Provenance-related functions ================/
/**
* Returns true if this provenance field points to a source of this data
* or metadata object
* @param {string} field - The provenance field to check
* @returns {boolean} True if this field is a source field, false
* otherwise
*/
isSourceField(field) {
if (typeof field === "undefined" || !field) return false;
if (!_.contains(MetacatUI.appSearchModel.getProvFields(), field))
return false;
if (
field === "prov_generatedByExecution" ||
field === "prov_generatedByProgram" ||
field === "prov_used" ||
field === "prov_wasDerivedFrom" ||
field === "prov_wasInformedBy"
)
return true;
return false;
},
/**
* Returns true if this provenance field points to a derivation of this
* data or metadata object
* @param {string} field - The provenance field to check
* @returns {boolean} True if this field is a derivation field, false
* otherwise
*/
isDerivationField(field) {
if (typeof field === "undefined" || !field) return false;
if (!_.contains(MetacatUI.appSearchModel.getProvFields(), field))
return false;
if (
field === "prov_usedByExecution" ||
field === "prov_usedByProgram" ||
field === "prov_hasDerivations" ||
field === "prov_generated"
)
return true;
return false;
},
/**
* Returns true if this SolrResult has a provenance trace (i.e. has either
* sources or derivations)
* @returns {boolean} True if this object has a provenance trace, false
* otherwise
*/
hasProvTrace() {
if (this.get("formatType") === "METADATA") {
if (this.get("prov_hasSources") || this.get("prov_hasDerivations"))
return true;
}
const fieldNames = MetacatUI.appSearchModel.getProvFields();
let currentField = "";
for (let i = 0; i < fieldNames.length; i += 1) {
currentField = fieldNames[i];
if (this.has(currentField)) return true;
}
return false;
},
/**
* Returns an array of all the IDs of objects that are sources of this
* object
* @returns {string[]} An array of source IDs
*/
getSources() {
const sources = [];
const model = this;
// Get the prov fields but leave out references to executions which are
// not used in the UI yet
const fields = _.reject(
MetacatUI.appSearchModel.getProvFields(),
(f) => f.indexOf("xecution") > -1,
); // Leave out the first e in execution so we don't have to worry about case sensitivity
_.each(fields, (provField, _i) => {
if (model.isSourceField(provField) && model.has(provField))
sources.push(model.get(provField));
});
return _.uniq(_.flatten(sources));
},
/**
* Returns an array of all the IDs of objects that are derivations of this
* object
* @returns {string[]} An array of derivation IDs
*/
getDerivations() {
const derivations = [];
const model = this;
// Get the prov fields but leave out references to executions which are
// not used in the UI yet
const fields = _.reject(
MetacatUI.appSearchModel.getProvFields(),
(f) => f.indexOf("xecution") > -1,
); // Leave out the first e in execution so we don't have to worry about case sensitivity
_.each(fields, (provField, _i) => {
if (model.isDerivationField(provField) && model.has(provField))
derivations.push(model.get(provField));
});
return _.uniq(_.flatten(derivations));
},
/** @returns {string[]} IDs of all objs that are used by this obj */
getInputs() {
return this.get("prov_used");
},
/** @returns {string[]} IDs of all objs that are generated by this obj */
getOutputs() {
return this.get("prov_generated");
},
/**
* Uses the app configuration to check if this model's metrics should be
* hidden in the display
* @returns {boolean} True if the metrics should be hidden, false
* otherwise
*/
hideMetrics() {
// If the AppModel is configured with cases of where to hide metrics,
if (
typeof MetacatUI.appModel.get("hideMetricsWhen") === "object" &&
MetacatUI.appModel.get("hideMetricsWhen")
) {
// Check for at least one match
return _.some(
MetacatUI.appModel.get("hideMetricsWhen"),
function hideWhen(value, modelProperty) {
// Get the value of this property from this model
const modelValue = this.get(modelProperty);
// Check for the presence of this model's value in the AppModel
// value
if (Array.isArray(value) && typeof modelValue === "string") {
return _.contains(value, modelValue);
}
// Check for the presence of the AppModel's value in this model's
// value
if (typeof value === "string" && Array.isArray(modelValue)) {
return _.contains(modelValue, value);
}
// Check for overlap of two arrays
if (Array.isArray(value) && Array.isArray(modelValue)) {
return _.intersection(value, modelValue).length > 0;
}
// If the AppModel value is a function, execute it
if (typeof value === "function") {
return value(modelValue);
}
// Otherwise, just check for equality
return value === modelValue;
},
this,
);
}
return false;
},
/**
* Creates a URL for viewing more information about this metadata
* @returns {string} The URL to view this metadata
*/
createViewURL() {
return this.getType() === "portal" || this.getType() === "collection"
? `${MetacatUI.root}/${MetacatUI.appModel.get(
"portalTermPlural",
)}/${encodeURIComponent(
this.get("label") || this.get("seriesId") || this.get("id"),
)}`
: `${MetacatUI.root}/view/${encodeURIComponent(
this.get("seriesId") || this.get("id"),
)}`;
},
/**
* Parses the resourceMap field from the Solr response JSON.
* @param {object} json - The JSON object from the Solr response
* @returns {string|string[]} The resourceMap parsed. If it is a string,
* it returns the trimmed string. If it is an array, it returns an array
* of trimmed strings. If it is neither, it returns an empty array.
*/
parseResourceMapField(json) {
return QueryService.parseResourceMapField(json);
},
},
);
return SolrResult;
});