Source: src/js/models/CitationModel.js

"use strict";

define(["jquery", "underscore", "backbone", "collections/Citations"], function (
  $,
  _,
  Backbone,
  Citations,
) {
  /**
   * @class CitationModel
   * @classdesc A Citation Model represents a single Citation Object returned by
   * the metrics-service. A citation model can alternatively be populated with a
   * SolrResultsModel or a DataONEObjectModel, or an extension of either of
   * those models. A Citation Model can represent a citation to a local
   * MetacatUI object, or an external document or publication.
   * @classcategory Models
   * @extends Backbone.Model
   * @see https://app.swaggerhub.com/apis/nenuji/data-metrics
   */
  var Citation = Backbone.Model.extend(
    /** @lends CitationModel.prototype */ {
      /**
       * The name of this type of model
       * @type {string}
       */
      type: "CitationModel",

      /**
       * The default Citation fields
       * @name CitationModel#defaults
       * @type {Object}
       * @property {string} origin - text of authors who published the source
       * dataset / document / article
       * @property {string[]} originArray - array of authors who published the
       * source dataset / document / article. Same as origin, but split on commas
       * and trimmed.
       * @property {string} title - Title of the source dataset / document /
       * article
       * @property {number} year_of_publishing - Year in which the source dataset
       * / document / article was published
       * @property {string} source_url - URL to the source dataset / document /
       * article. This is usually an external publication that cites one or more
       * DataONE datasets.
       * @property {string} source_id - Unique identifier for the source dataset /
       * document / article that cited the target dataset. This is usually an
       * external publication that cites one or more DataONE datasets.
       * @property {string} target_id - Unique identifier to the target DATAONE
       * dataset. This is the dataset that was cited by the "source" document.
       * @property {string} publisher - Publisher for the source dataset /
       * document / article
       * @property {string} journal - The journal where the the document was
       * published
       * @property {number|string} volume - The volume of the journal where the
       * document was published
       * @property {number} page - The page of the journal where the document was
       * published
       * @property {Citations} citationMetadata - When this Citation Model refers
       * to an external document, citationMetadata is a collection of DataONE
       * datasets that the external document cites. This info is retrieved by the
       * metrics service, then parsed and stored as a collection of Citation
       * Models. This attribute is used in the Portals view, for example, where we
       * display a list of external publications that cite the portal data. In
       * this case, each publication's citationMetadata is the list of local
       * MetacatUI data packages cited in the publication.
       * @property {Backbone.Model} sourceModel - The model to use to populate
       * this citation model. This can be a SolrResultsModel, a
       * DataONEObjectModel, or an extension of either of those models. Do not set
       * this attribute directly. Instead, use the setSourceModel() method.
       * @property {string} pid - The pid or unique identifier of the object being
       * cited.
       * @property {string} seriesId - The seriesId of the object being cited
       * @property {string} view_url - For citations that are in the local
       * MetacatUI repository, this is the URL to the metadata view page for the
       * object being cited.
       * @property {string} pid_url - If the pid is a DOI, then this is the URL to
       * the DOI landing page for the object being cited. This will automatically
       * be set when the pid attribute is set.
       * @property {string} seriesId_url - If the seriesId is a DOI, then this is
       * the URL to the DOI landing page for the object being cited. This will
       * automatically be set when the seriesId attribute is set.
       */
      defaults: function () {
        return {
          origin: null,
          originArray: [],
          title: null,
          year_of_publishing: null,
          source_url: null,
          source_id: null,
          target_id: null,
          publisher: null,
          journal: null,
          volume: null,
          page: null,
          citationMetadata: null,
          sourceModel: null,
          pid: null,
          seriesId: null,
          view_url: null,
          pid_url: null,
          seriesId_url: null,
        };
      },

      /**
       * Get the attribute getters for this model. "Attribute getters" are
       * functions that return the value of an attribute for this Citation Model
       * given a source model. The source model can be a SolrResultsModel, a
       * DataONEObjectModel, or an extension of either of those models.
       * @returns {Object} - An object that maps the name of the CitationModel
       * attribute to the function that returns the value for that attribute.
       */
      attrGetters: function () {
        return {
          year_of_publishing: this.getYearFromSourceModel,
          title: this.getTitleFromSourceModel,
          journal: this.getJournalFromSourceModel,
          pid: this.getPidFromSourceModel,
          seriesId: this.getSeriesIdFromSourceModel,
          originArray: this.getOriginArrayFromSourceModel,
          view_url: this.getViewUrlFromSourceModel,
        };
      },

      /**
       * Override the default Backbone.Model.parse() method to convert the
       * citationMetadata object into a nested collection of CitationModels.
       * @param {Object} response - The response from the metrics-service API
       * @param {Object} options - Options to pass to the parse() method.
       * @returns {Object} The parsed response
       */
      parse: function (response) {
        try {
          // strings that need formatting when coming from the metrics-service:
          const toFormat = ["journal", "page", "volume", "publisher"];
          toFormat.forEach((attr) => {
            response[attr] = this.formatMetricsServiceString(response[attr]);
          }, this);

          // Turn the author strings into CSL JSON objects
          if (response.origin) {
            const or = this.originToArray(response.origin);
          }
          let sID = response.source_id;
          if (this.isDOI(sID)) {
            if (sID.startsWith("http")) {
              sID = this.URLtoDOI(sID);
            }
            if (!sID.startsWith("doi:")) {
              sID = "doi:" + sID;
            }
            response.source_id = sID;
          }

          // Format the citation metadata = DataONE datasets cited by this
          // citation (external document)
          const cm = response.citationMetadata;
          if (cm) {
            if (cm && !(cm instanceof Citations)) {
              const citationMetadata = Object.entries(cm).map(([pid, data]) => {
                // Convert format from {id: {data}} to {data, id}
                const item = { ...data, pid };
                // Origin returned by metrics-service is actually an array, not a
                // string
                item.originArray = item.origin;
                delete item.origin;
                // Format the authors in the origin array
                item.originArray = item.originArray.map((author) =>
                  this.formatAuthor(author),
                );
                // Get the publish year
                const date =
                  item.datePublished || item.dateUpdated || item.dateModified;
                item.year_of_publishing = date
                  ? new Date(date).getUTCFullYear()
                  : null;
                // Because the citation metadata is always referencing an object
                // in the local MetacatUI repository, we assume that the view_url
                // exists for the given PID.
                // DOIs from the metrics service are not prefixed with "doi:"
                if (this.isDOI(pid) && !pid.startsWith("doi:")) {
                  pid = "doi:" + pid;
                }
                item.pid = pid;
                item.view_url =
                  MetacatUI.root + "/view/" + encodeURIComponent(pid);

                return item;
              });
              response.citationMetadata = new Citations(citationMetadata);
            }
          }
          return response;
        } catch (error) {
          console.log(
            "Error parsing a CitationModel. Returning response as-is.",
            error,
          );
          return response;
        }
      },

      /**
       * Override the default Backbone.Model.set() method to format the title,
       * page, and volume attributes before setting them, and ensure that
       * attributes that are different formats of the same value are in sync,
       * including: origin and originArray; pid and pid_url; seriesId and
       * seriesId_url. This method will prevent the sourceModel attribute from
       * being set here.
       *
       * @param {string|Object} key - The attribute name to set, or an object of
       * attribute names and values to set.
       * @param {string|number|Object} val - The value to set the attribute to.
       * @param {Object} options - Options to pass to the set() method.
       * @see https://backbonejs.org/#Model-set
       * @since 2.23.0
       */
      set: function (key, val, options) {
        try {
          if (key == null) return this;

          // Handle both `"key", value` and `{key: value}` -style arguments.
          let attrs = {};
          if (typeof key === "object") {
            attrs = key;
            options = val;
          } else {
            (attrs = {})[key] = val;
          }

          // Don't allow setting the sourceModel attribute here.
          // TODO: how to handle this better?
          delete attrs.sourceModel;

          // If the title attribute is being set, then format it first
          if (Object.keys(attrs).includes("title")) {
            attrs.title = this.formatTitle(attrs.title);
          }

          // Ensure origin and originArray contain the same data, with preference
          // given to originArray. If originArray has content, then overwrite
          // origin with the a string created from originArray. If *only* origin
          // has content, then overwrite originArray with an array created from
          // origin.
          if (
            Object.keys(attrs).includes("originArray") ||
            Object.keys(attrs).includes("origin")
          ) {
            const strToArray = this.originToArray(attrs.origin);
            const arrayToStr = this.originArrayToString(attrs.originArray);
            if (!!arrayToStr) {
              attrs.origin = arrayToStr;
            } else {
              attrs.originArray = strToArray;
            }
          }

          // Ensure that the pid_url and seriesId_url attributes match the pid and
          // seriesId attributes being set, and vice versa. If they don't match,
          // then set them to the correct values. Prefer the content of the IDs
          // over the URLs.
          const idToUrlAttrs = [
            { id: "pid", url: "pid_url" },
            { id: "seriesId", url: "seriesId_url" },
          ];

          idToUrlAttrs.forEach(({ id, url }) => {
            if (
              Object.keys(attrs).includes(id) ||
              Object.keys(attrs).includes(url)
            ) {
              if (!!attrs[id] && !attrs[url]) {
                attrs[url] = this.DOItoURL(attrs[id]);
              } else if (!!attrs[url] && !attrs[id]) {
                attrs[id] = this.URLtoDOI(attrs[url]);
              } else if (!!attrs[id] && !!attrs[url]) {
                attrs[url] = this.DOItoURL(attrs[id]);
              }
            }
          });

          // If citationMetadata is being changed, remove old listeners and add
          // new ones
          if (Object.keys(attrs).includes("citationMetadata")) {
            if (this.citationMetadata) {
              this.stopListening(this.citationMetadata);
            }
            if (attrs.citationMetadata && attrs.citationMetadata.length) {
              this.listenTo(
                attrs.citationMetadata,
                "update",
                this.trigger.bind(this, "change"),
              );
            }
          }

          // Set modified attributes in the regular Backbone way
          Backbone.Model.prototype.set.call(this, attrs, options);
        } catch (error) {
          console.log(
            "Error in custom set() method on CitationModel. Will attempt to set" +
              " using with Backbone set(). Attributes and error stack trace:",
            { key, val, options },
            error,
          );
          Backbone.Model.prototype.set.call(this, key, val, options);
        }
      },

      /**
       * Sets the sourceModel attribute and calls the method to populate the
       * Citation Model with the sourceModel attributes. Also removes any existing
       * listeners on the previous sourceModel and readds them to the new
       * sourceModel. Use this method to set or change the sourceModel attribute.
       * @param {Backbone.Model} newSourceModel - The new sourceModel
       * @since 2.23.0
       */
      setSourceModel(newSourceModel) {
        try {
          newSourceModel =
            newSourceModel && newSourceModel.type == "Package"
              ? newSourceModel.getMetadata()
              : newSourceModel;

          // Remove any existing listeners on the previous sourceModel
          const currentSourceModel = this.get("sourceModel");
          if (currentSourceModel) {
            this.stopListening(currentSourceModel);
            const creators = currentSourceModel.get("creator") || [];
            creators.forEach((creator) => this.stopListening(creator), this);
          }

          // Add listeners to the new sourceModel
          if (newSourceModel) {
            const creatorEvents =
              "change:individualName change:organizationName change:positionName";
            const sourceModelEvents =
              "change:origin change:creator change:pubDate change:dateUploaded change:title change:seriesId change:id change:datasource";
            const creators = newSourceModel.get("creator") || [];
            this.listenTo(newSourceModel, sourceModelEvents, () => {
              this.setSourceModel(newSourceModel);
            });
            creators.forEach((creator) => {
              this.listenTo(creator, creatorEvents, () => {
                this.setSourceModel(newSourceModel);
              });
            });
          }
          Backbone.Model.prototype.set.call(
            this,
            "sourceModel",
            newSourceModel,
          );
          this.populateFromModel(newSourceModel);
        } catch (error) {
          console.log("Error in CitationModel.setSourceModel(). Error:", error);
        }
      },

      /**
       * Do not call this method directly. Instead, call setSourceModel(), which
       * will update listeners and then call this method. This method will
       * populate this citation model's attributes from another model, such as a
       * SolrResult model or a DataONEObject model. This will reset and overwrite
       * any existing attributes on this model.
       * @param {Backbone.Model} model - The model to populate from, accepts
       * SolrResult or a model that is a DataONEObject or an extended
       * DataONEObject. If no model is passed, then the model will be reset to the
       * default attributes.
       * @since 2.23.0
       */
      populateFromModel: function (newSourceModel) {
        try {
          // Populate this model from the new sourceModel

          const newAttrs = this.defaults();

          if (!newSourceModel) {
            this.set(newAttrs);
            return;
          }

          const attrGetters = this.attrGetters();

          Object.entries(attrGetters).forEach(([attrName, getter]) => {
            const attrValue = getter.call(this, newSourceModel);
            if (attrValue) newAttrs[attrName] = attrValue;
          });

          this.set(newAttrs);
        } catch (error) {
          console.log(
            "Error populating a CitationModel from the model: ",
            newSourceModel,
            " Error: ",
            error,
          );
        }
      },

      /**
       * Get the year from the sourceModel. First look for pubDate, then
       * dateUploaded (both in SolrResult & ScienceMetadata/EML models). Lastly
       * check datePublished (found in ScienceMetadata/EML models only.)
       * @param {Backbone.Model} sourceModel - The model to get the year from
       * @returns {Number} - The year
       * @since 2.23.0
       */
      getYearFromSourceModel(sourceModel) {
        try {
          const year =
            this.yearFromDate(sourceModel.get("pubDate")) ||
            this.yearFromDate(sourceModel.get("dateUploaded")) ||
            this.yearFromDate(sourceModel.get("datePublished"));
          return year;
        } catch (error) {
          console.log(
            "Error getting year from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().year_of_publishing;
        }
      },

      /**
       * Get the title from the sourceModel
       * @param {Backbone.Model} sourceModel - The model to get the title from
       * @returns {String} - The title
       * @since 2.23.0
       */
      getTitleFromSourceModel(sourceModel) {
        try {
          let title = sourceModel.get("title");
          title = Array.isArray(title) ? title[0] : title;
          // If this is a Data object, there may not be a title, so try to get the
          // title from the file name
          if (!title && sourceModel.get("fileName")) {
            let fn = sourceModel.get("fileName");
            const extRegex = /\.[^/.]+$/;
            // Save the extension
            let ext = fn ? fn.match(extRegex) : null;
            // remove the period and make it all uppercase
            ext = ext ? ext[0].replace(".", "").toUpperCase() : ext;
            // Remove the extension and replace underscores with spaces
            fn = fn.replace(extRegex, "").replace(/_+/g, " ");
            title = fn ? fn : title;
            title = title && ext ? title + " [" + ext + "]" : title;
          }
          return title;
        } catch (error) {
          console.log(
            "Error getting title from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().title;
        }
      },

      /**
       * Get the journal (datasource/node) from the sourceModel. If there is a
       * datasource attribute on the sourceModel, then get the name of the member
       * node that has that datasource ID. If we can't find a member node that
       * matches the datasource, then check if the datasource is the current node.
       * If it is, then use the repository name. If there is no datasource
       * attribute, then use the current member node's name.
       * @param {Backbone.Model} sourceModel - The model to get the journal from
       * @returns {String} - The journal
       * @since 2.23.0
       */
      getJournalFromSourceModel(sourceModel) {
        try {
          let journal = null;
          const datasource = sourceModel.get("datasource");
          const mn = MetacatUI.nodeModel.getMember(datasource);
          const currentMN = MetacatUI.nodeModel.get("currentMemberNode");
          if (datasource) {
            if (mn) {
              journal = mn.name;
            } else if (datasource == MetacatUI.appModel.get("nodeId")) {
              journal = MetacatUI.appModel.get("repositoryName");
            }
          }
          if (!journal && currentMN) {
            const mnCurrent = MetacatUI.nodeModel.getMember(currentMN);
            journal = mnCurrent ? mnCurrent.name : null;
          }
          return journal;
        } catch (error) {
          console.log(
            "Error getting journal from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().journal;
        }
      },

      /**
       * Get the array of authors ("origin") from the sourceModel. First look for
       * creator (EML), then origin (science metadata & solr results), then
       * rightsHolder & submitter (base D1 object model). Convert EML parties to
       * strings & check for incorrectly escaped characters.
       * @param {Backbone.Model} sourceModel - The model to get the originArray
       * from
       * @returns {Array} - The originArray
       * @since 2.23.0
       */
      getOriginArrayFromSourceModel(sourceModel) {
        try {
          // AUTHORS
          let authors =
            // If it's an EML document, there will be a creator field
            sourceModel.get("creator") ||
            // If it's a science metadata model or solr results, use origin
            sourceModel.get("origin") ||
            "";

          // otherwise, this is probably a base D1 object model. Don't use
          // rightsHolder or submitter for now, because it might not always be the
          // author.

          // sourceModel.get("rightsHolder") ||
          // sourceModel.get("submitter");

          // Convert EML parties to strings & check for incorrectly escaped
          // characters
          if (authors) {
            authors = Array.isArray(authors) ? authors : [authors];
            authors = authors.map((author) => this.formatAuthor(author));
          }
          return authors;
        } catch (error) {
          console.log(
            "Error getting originArray from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().originArray;
        }
      },

      /**
       * Get the pid from the sourceModel. First look for id, then identifier.
       * @param {Backbone.Model} sourceModel - The model to get the pid from
       * @returns {String} - The pid
       * @since 2.23.0
       */
      getPidFromSourceModel(sourceModel) {
        try {
          const pid =
            sourceModel.get("id") || sourceModel.get("identifier") || null;
          return pid;
        } catch (error) {
          console.log(
            "Error getting the pid from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().pid;
        }
      },

      /**
       * Get the seriesId from the sourceModel. Simply looks for the seriesId
       * attribute.
       * @param {Backbone.Model} sourceModel - The model to get the seriesId from
       * @returns {String} - The seriesId
       * @since 2.23.0
       */
      getSeriesIdFromSourceModel(sourceModel) {
        try {
          const seriesId = sourceModel.get("seriesId") || null;
          return seriesId;
        } catch (error) {
          console.log(
            "Error getting the seriesId from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().seriesId;
        }
      },

      /**
       * Use the sourceModel's createViewURL() method to get the viewUrl for the
       * citation. This method is built into DataONEObject models, SolrResult
       * models, as  well as Portal models. If the sourceModel doesn't have a
       * createViewURL() method, then use the default viewUrl (null)
       * @param {Backbone.Model} sourceModel - The model to get the viewUrl from
       * @returns {String} - The viewUrl, or null if the sourceModel doesn't have
       * a createViewURL() method.
       * @since 2.23.0
       */
      getViewUrlFromSourceModel(sourceModel) {
        try {
          if (sourceModel && sourceModel.createViewURL) {
            return sourceModel.createViewURL();
          } else {
            return this.defaults().viewUrl;
          }
        } catch (error) {
          console.log(
            "Error getting the viewUrl from the sourceModel. Model and error:",
            sourceModel,
            error,
          );
          return this.defaults().viewUrl;
        }
      },

      /**
       * Format an individual author for display within a citation.
       * @param {string|EMLParty} author The author to format
       * @returns {string} Returns the author as a string if it was an EMLParty
       * with any incorrectly escaped characters corrected.
       */
      formatAuthor: function (author) {
        try {
          // Update the origin array asynchonously if the author is an ORCID
          if (this.isOrcid(author)) this.originArrayFromOrcid(author);

          // If author is an EMLParty model, then convert it to a string with
          // given name + sur name, or organization name
          if (typeof author.toCSLJSON === "function") {
            author = author.toCSLJSON();
          } else if (typeof author === "string") {
            author = this.nameStrToCSLJSON(author);
          }

          return author;
        } catch (error) {
          console.log(
            "There was an error formatting an author, returning " +
              "the author input as is.",
            error,
          );
          return author;
        }
      },

      /**
       * Cleans up the title for display within a citation. Removes a period from
       * the end of the title if it exists and trims whitespace.This method is
       * called any time a title is set on the Citation model.
       * @param {string} title The title to format
       * @returns {string} Returns the title with a period removed from the end if
       * it exists.
       * @since 2.23.0
       */
      formatTitle: function (title) {
        if (!title) return "";
        return title.replace(/\.+$/, "").trim();
      },

      /**
       * Cleans up the metrics service string for display within a citation.
       * Replaces "NULL" with an empty string, removes a period from the end of
       * the string if it exists, removes curly braces, and trims whitespace.
       * @param {string} str The metrics service string to format
       * @returns {string} Returns the metrics service string with "NULL" replaced
       * with an empty string.
       * @since 2.23.0
       */
      formatMetricsServiceString: function (str) {
        if (!str) return "";
        // The metrics service returns "NULL" if there is no data
        str = str === "NULL" ? "" : str;
        // Replace period at the end of the string
        str = str.replace(/\.+$/, "");
        // Remove curly braces
        str = str.replace(/{|}/g, "");
        // Remove any leading or trailing whitespace
        str = str.trim();
        // Check for incorrectly escaped characters, like &
        const doc = new DOMParser().parseFromString(str, "text/html");
        str = doc.body.textContent || "";
        return str;
      },

      /**
       * Convert the author string that is returned from the metrics service into
       * CSL JSON format. Author strings that come from the metrics service take
       * many formats, which might include full given and last names, middle
       * initials, first initials, etc. Here are a few example strings: "Chelsea
       * Wegner Koch", "Lee W. Cooper", "J. Wiktor", "Sei-Ichi Saitoh", "William
       * K. W. Li", "J.R. Lovvorn". Last name prefixes like "van" or "de" are
       * stored as a "non-dropping particle". See:
       * {@link https://citeproc-js.readthedocs.io/en/latest/csl-json/markup.html#name-variables}
       *
       * @param {str} author The author string to convert
       * @returns {Object} Returns an object with the author's name in CSL JSON
       * format.
       * @since 2.23.0
       */
      nameStrToCSLJSON: function (str) {
        if (!str) return null;
        const name = {};
        str = this.formatMetricsServiceString(str);

        // If the string contains one comma, then assume it is in the format "last
        // name, first name". Move the first name to the front of the string.
        if (str.split(",").length == 2) {
          const parts = str.split(",");
          str = parts[1].trim() + " " + parts[0].trim();
        }

        const parts = str
          .trim()
          .split(/\s+|\./)
          .filter((part) => part !== "");

        if (parts.length === 1) {
          name.literal = str;
          return name;
        }

        // Assume the last word is the family name
        name.family = parts.pop();

        // Any remaining lowercase words are assumed to be non-dropping particles
        const nonDroppingParticles = parts.filter((part) =>
          part.match(/^[a-z]+$/),
        );
        if (nonDroppingParticles.length > 0) {
          name["non-dropping-particle"] = nonDroppingParticles.join(" ");
        }

        // Any remaining words are assumed to be given names
        const givenNames = parts.filter((part) => !part.match(/^[a-z]+$/));
        if (givenNames.length > 0) {
          name.given = givenNames.join(" ");
        }

        return name;
      },

      /**
       * Given a date, extract the year as a number.
       * @param {Date|String|Number} date The date to extract the year from
       * @returns {Number} Returns the year as a number, or null if the date is
       * invalid.
       * @since 2.23.0
       */
      yearFromDate: function (date) {
        try {
          if (!date) return null;
          // If Date is already a year (Number object with 4 digits), return it
          if (Number.isInteger(date) && date.toString().length == 4) {
            return date;
          }
          // If it is a string with 4 digits, return it as an integer. Use regex.
          if (typeof date == "string" && /^\d{4}$/.test(date)) {
            return parseInt(date);
          }
          // Check if the date is a Date object
          if (!(date instanceof Date)) {
            date = new Date(date);
          }
          const yr = date.getUTCFullYear();
          return yr == "NaN" ? null : yr;
        } catch (error) {
          console.log(
            "There was an error getting the year from the date, returning null.",
            error,
          );
          return null;
        }
      },

      /**
       * Check if a string is a valid ORCID.
       * @param {string} orcid The ORCID to check
       * @returns {boolean} Returns true if the ORCID is valid, false otherwise
       * @since 2.23.0
       */
      isOrcid: function (orcid) {
        try {
          if (!orcid) return false;
          const regex = new RegExp(
            "^https?:\\/\\/orcid.org\\/(\\d{4}-){3}(\\d{3}[0-9X])$",
          );
          return regex.test(orcid);
        } catch {
          return false;
        }
      },

      /**
       * Use the App Lookup model's get Accounts method to get the name of the
       * author from their ORCID, then asynchronously set the originArray to
       * contain that name.
       * @param {string} orcid The ORCID to get the name for
       * @since 2.23.0
       */
      originArrayFromOrcid: function (orcid) {
        try {
          const request = { term: orcid };
          const model = this;

          const callback = function (response) {
            let name = null;
            if (response) {
              if (Array.isArray(response)) {
                const label = response[0].label;
                if (label) {
                  // Name is the format "Min Liew
                  // (http://orcid.org/0000-0002-5156-4610)" We want to return
                  // "Min Liew". It will always be two spaces and a "("
                  name = label.split("  (")[0];
                }
              }
            }
            if (name) {
              console.log("Setting originArray to ", [name]);
              model.set("originArray", [name]);
            }
          };
          MetacatUI.appLookupModel.getAccountsAutocomplete(request, callback);
        } catch (error) {
          console.log(
            "There was an error getting the name from the orcid.",
            error,
          );
        }
      },

      /**
       * Checks if the citation is for a DataONE object from a specific node (e.g.
       * PANGAEA)
       * @param {string} node - The node id to check, e.g. "urn:node:PANGAEA"
       * @returns {boolean} - True if the citation is for a DataONE object from
       * the given node
       * @since 2.23.0
       */
      isFromNode: function (node) {
        try {
          const sourceModel = this.get("sourceModel");
          return (
            sourceModel &&
            sourceModel.get &&
            sourceModel.get("datasource") &&
            sourceModel.get("datasource") === node
          );
        } catch (error) {
          console.log(
            `There was an error checking if the citation is from node ${node}.` +
              `Returning false.`,
            error,
          );
          return false;
        }
      },

      /**
       * Convert the comma-separated origin string to an array of authors.
       * @param {string} origin - The origin string to convert to an array. If a
       * falsy value is passed in, then the default originArray attribute of the
       * model is returned.
       * @returns {Array} - An array of authors
       * @since 2.23.0
       */
      originToArray: function (origin) {
        try {
          if (!origin) {
            return this.defaults().originArray;
          }
          let originArray = origin ? origin.split(", ") : [];
          return originArray.map((author) => this.formatAuthor(author));
        } catch (error) {
          console.log(
            "There was an error converting the origin string to an array.",
            error,
          );
          return this.defaults().originArray;
        }
      },

      /**
       * Convert the origin array to a string.
       * @returns {string} - The origin string. If a falsy value is passed in,
       * then the default origin attribute of the model is returned.
       * @since 2.23.0
       */
      originArrayToString: function (originArray) {
        try {
          if (!originArray || !originArray.length) {
            return this.defaults().origin;
          }
          // Each author in the array is a CSL JSON object. Map it to a string
          // with the author's name in the format First Particle Last or Literal.
          // Separate each author name with a comma and a space.
          const origin = originArray
            .map((a) => {
              if (!a) return null;
              const ndp = a["non-dropping-particle"];
              let name =
                (a.given ? a.given + " " : "") +
                (ndp ? ndp + " " : "") +
                (a.family ? a.family : "");
              return (name || a.literal || "").trim();
            })
            .filter((a) => a)
            .join(", ");
          return origin;
        } catch (error) {
          console.log(
            "There was an error converting the origin array to a string.",
            error,
          );
          return this.defaults().origin;
        }
      },

      /**
       * Returns true if the citation is for a DataONE object that has been
       * archived and archived content is not available in the search index.
       * @returns {boolean} - True if the citation has no content because it is
       * archived and archived content is not indexed.
       * @see AppModel#archivedContentIsIndexed
       * @since 2.23.0
       */
      isArchivedAndNotIndexed: function () {
        return this.isArchived() && !this.archivedContentIsIndexed();
      },

      /**
       * Checks if the object being cited is archived, according to the `archived`
       * attribute of the source model.
       * @returns {boolean} - True if the source model has an `archived` attribute
       * that is true.
       * @since 2.23.0
       */
      isArchived: function () {
        return (
          this.sourceModel &&
          this.sourceModel.get &&
          this.sourceModel.get("archived")
        );
      },

      /**
       * Checks if archived content is available in the search index.
       * @see AppModel#archivedContentIsIndexed
       * @returns {boolean} - True if archived content is available in the search
       * index.
       * @since 2.23.0
       */
      archivedContentIsIndexed: function () {
        return MetacatUI.appModel.get("archivedContentIsIndexed");
      },

      /**
       * Remove all DOI prefixes from a DOI string, including https, http, doi.org,
       * dx.doi.org, and doi:.
       * @param {string} str - The DOI string to remove prefixes from.
       * @returns {string} - The DOI string without any prefixes.
       * @since 2.23.0
       */
      removeAllDOIPrefixes: function (str) {
        if (!str) return "";
        // Remove https and http prefixes
        str = str.replace(/^(https?:\/\/)?/, "");
        // Remove domain prefixes, like doi.org and dx.doi.org
        str = str.replace(/^(doi\.org\/|dx\.doi\.org\/)/, "");
        // Remove doi: prefix
        str = str.replace(/^doi:/, "");
        return str;
      },

      /**
       * Check if a string is a valid DOI.
       * @param {string} doi - The string to check.
       * @returns {boolean} - True if the string is a valid DOI, false otherwise.
       * @since 2.23.0
       */
      isDOI: function (str) {
        return MetacatUI.appModel.isDOI(str);
      },

      /**
       * Get the URL for the online location of the object being cited when it has
       * a DOI. If the DOI is not passed to the function, or if the string is not
       * a DOI, then an empty string is returned.
       * @param {string} [str] - The DOI string with or without the "doi:" prefix.
       * It may already be a URL, or it may be a DOI string. It also handles the
       * case where the DOI is already a URL.
       * @returns {string} - The DOI URL
       * @since 2.23.0
       */
      DOItoURL: function (str) {
        return MetacatUI.appModel.DOItoURL(str);
      },

      /**
       * Get the DOI from a DOI URL. The URL can be http or https, can include the
       * "doi:" prefix or not, and can use "dx.doi.org" or "doi.org" as the
       * domain. If a string is not passed to the function, or if the string is
       * not for a DOI URL, then an empty string is returned.
       * @param {string} url - The DOI URL
       * @returns {string} - The DOI string, including the "doi:" prefix
       * @since 2.23.0
       */
      URLtoDOI: function (url) {
        return MetacatUI.appModel.URLtoDOI(url);
      },

      /**
       * Checks if the citation has a DOI in the seriesId or pid attributes.
       * @returns {string} - The DOI of the seriesID, if it is a DOI, or the DOI
       * of the pid, if it is a DOI. Otherwise, returns null.
       * @since 2.23.0
       */
      findDOI: function () {
        try {
          if (!this.sourceModel || !this.sourceModel.isDOI) return null;
          const seriesID = this.get("seriesId");
          const pid = this.get("pid");
          if (this.sourceModel.isDOI(seriesID)) return seriesID;
          if (this.sourceModel.isDOI(pid)) return pid;
          return null;
        } catch (error) {
          console.log(
            "There was an error finding the DOI for the citation. Returning null",
            error,
          );
          return null;
        }
      },

      /**
       * Checks if the citation has a DOI in the seriesId or pid attributes.
       * @returns {boolean} - True if the citation has a DOI
       * @since 2.23.0
       */
      hasDOI: function () {
        return this.findDOI() ? true : false;
      },

      /**
       * If this citation has a source model, and if that source model is a
       * DataONEObject, then return the results of the DataONEObject's
       * `getUploadStatus` function
       * @returns {string} - The upload status of the source model, if it is a
       * DataONEObject, or null if it is not.
       * @since 2.23.0
       * @see DataONEObject#getUploadStatus
       */
      getUploadStatus: function () {
        return this.sourceModel ? this.sourceModel.get("uploadStatus") : null;
      },

      /**
       * Get the URL for the citation. This will check the model for the following
       * attributes and return the first that is not empty: view_url, source_url,
       * sid_url, pid_url.
       * @returns {string} Returns the URL for the citation or an empty string.
       * @since 2.23.0
       */
      getURL: function () {
        const urlSources = ["view_url", "source_url", "sid_url", "pid_url"];
        for (let i = 0; i < urlSources.length; i++) {
          const url = this.get(urlSources[i]);
          if (url) return url;
        }
        return "";
      },

      /**
       * Get the main identifier for the citation. This will check the model for
       * the following attributes and return the first that is not empty: pid,
       * seriesId, source_url.
       * @returns {string} Returns the main identifier for the citation or an
       * empty string.
       * @since 2.23.0
       */
      getID: function () {
        const idSources = ["pid", "seriesId", "source_url"];
        for (let i = 0; i < idSources.length; i++) {
          const id = this.get(idSources[i]);
          if (id) return id;
        }
        return "";
      },
    },
  );

  return Citation;
});