User:PresN/nominations viewer.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>
// Nominations Viewer
//
// Description: Compact nominations for [[WP:FAC]], [[WP:FAR]], [[WP:FLC]],
//   [[WP:FLRC]], [[WP:FPC]], and [[WP:PR]].
// Documentation: [[Wikipedia:Nominations Viewer]]
//
// ===
//
// Settings
// ---
//
// Default:
//
// NominationsViewer =
// {
//   'enabledPages': ['Wikipedia:Featured article candidates', ...],
//   'nominationData': ['images', 'age', 'nominators', 'participants', 'votes'],
// }
$(() => {
    // Check the URL to determine if this script should be disabled.
    if (window.location.href.includes('&disable=nomviewer')) {
      return;
    }
  
    // Check if already ran elsewhere.
    if (window.nominationsViewer) {
      return;
    }
  
    window.nominationsViewer = true;
  
    const NominationsViewer = window.NominationsViewer || {};
  
    if (!NominationsViewer.enabledPages) {
      NominationsViewer.enabledPages = {
        'User:Gary/sandbox': 'nominations',
  
        'Wikipedia:Featured article candidates': 'nominations',
        'Wikipedia:Featured article review': 'reviews',
  
        'Wikipedia:Featured list candidates': 'nominations',
        'Wikipedia:Featured list removal candidates': 'reviews',
  
        'Wikipedia:Featured picture candidates': 'pictures',
  
        'Wikipedia:Peer review': 'peer reviews',
      };
    }
  
    if (!NominationsViewer.nominationData) {
      NominationsViewer.nominationData = [
        'images',
        'age',
        'lastedit',
        'nominators',
        'participants',
        'votes',
      ];
    }
  
    /**
     * Add empty nomination data holders for a nomination.
     *
     * @param {string} pageName Name of the nomination page.
     * @param {jQuery} $parentNode Parent node containing the entire nomination.
     * @param {Array} ids The ID names to create.
     * @returns {jQuery} The new node we added.
     */
    function addNominationData(pageName, $parentNode, ids) {
      return ids.map((id) => {
        const $span = $(`<span id="${id}-${simplifyPageName(pageName)}"></span>`);
  
        return $parentNode
          .children()
          .last()
          .before($span);
      });
    }
  
    function addAllNomInfo($headings) {
      const data = { allH3Length: $headings.length };
  
      const $expandAllLink = $(
        '<a href="#" id="expand-all-link">expand all</a>'
      ).on('click', data, expandAllNoms);
      const $collapseAllLink = $(
        '<a href="#" id="collapse-all-link">collapse all</a>'
      ).on('click', data, collapseAllNoms);
      const $info = $('<span class="overall-controls"></span>')
        .append(' (')
        .append($expandAllLink)
        .append(' / ')
        .append($collapseAllLink)
        .append(')');
  
      return $headings
        .first()
        .next()
        .prevUntil('h2')
        .last()
        .prev()
        .append($info);
    }
  
    /**
     * Call the Wikipedia API with params then run a function on the return data.
     *
     * @param {Object} params The params to pass to the Wikipedia API.
     * @param {Function} callback The function to run with the return data.
     * @returns {undefined}
     */
    function addNomData(params, callback) {
      $.getJSON(mw.util.wikiScript('api'), {
        format: 'json',
        ...params,
      })
        .done(callback)
        .fail(() => {});
    }
  
    /**
     * Add all data to a nomination.
     *
     * @param {string} pageName The page name.
     * @returns {undefined}
     */
    function addAllNomData(pageName) {
      // Participants, age. Get all the edits for this nomination.
      addNomData(
        {
          action: 'query',
          prop: 'revisions',
          rvdir: 'newer',
          rvlimit: 500,
          titles: pageName,
        },
        allRevisionsCallback
      );
  
      // Images, nominators, votes. Get the contents of the latest version of this
      // nomination.
      addNomData(
        {
          action: 'query',
          prop: 'revisions',
          rvdir: 'older',
          rvlimit: 1,
          rvprop: 'content',
          titles: pageName,
        },
        currentRevisionCallback
      );
    }
  
    /**
     * Add data to a nomination.
     *
     * @param {Object} options Options
     * @param {string} options.pageName The page name to which to add this data.
     * @param {string} options.data The data to add.
     * @param {string} options.id The ID of the field to add to.
     * @param {string} options.hoverText Data that appears on hover.
     * @returns {undefined}
     */
    function addNewNomData({ pageName, data, id, hoverText }) {
      if (!data) {
        return;
      }
  
      // Select the element we want to add values to.
      const $id = $(`#${id}-${simplifyPageName(pageName)}`);
      const $newChild = $('<span class="nomv-data"></span>');
      const $abbr = $(`<abbr title="${hoverText}">${data}</abbr>`);
  
      $newChild.append($abbr);
      $id.append($newChild);
    }
  
    /**
     * Create the data that appears next to the nomination's listing.
     *
     * @param {string} pageName Page name of the nomination page.
     * @returns {jQuery} The new node we added.
     */
    function createData(pageName) {
      const $newSpan = $('<span class="nomination-data"></span>').append(
        '<span>(<span>'
      );
      const matchArchiveNumber = pageName.match(/(\d+)$/);
  
      const conditions = matchArchiveNumber && matchArchiveNumber[1] > 1;
      const matchArchiveNumberPrint = (() => {
        if (conditions) {
          const number = parseInt(matchArchiveNumber[1], 10);
  
          const ordinalSuffix = (() => {
            switch (number) {
              case 2:
                return 'nd';
              case 3:
                return 'rd';
              default:
                return 'th';
            }
          })();
  
          return `: ${number}${ordinalSuffix}`;
        }
  
        return '';
      })();
  
      const $viewLink = $(
        `<span><a href="${mw.util.getUrl(pageName)}">nomination</a>\
  ${matchArchiveNumberPrint}</span>`
      );
  
      return $newSpan.append($viewLink).append('<span>)<span>');
    }
  
    function createNewNode({ oldNode, showHideLink, newSpan, index }) {
      const $newNode = $(`<div id="nom-title-${index}"></div>`).append(
        oldNode.clone(true)
      );
      const $heading = $newNode.children().first();
  
      $heading
        .prepend(`<span class="nomination-order">${index + 1}.</span> `)
        .append(' ')
        .append(showHideLink)
        .append(newSpan);
  
      return $newNode;
    }
  
    /**
     * Replace a nomination with a new and improved one.
     *
     * @param {Object} options Options
     * @param {jQuery} options.$h3 The h3 heading of the nomination.
     * @param {number} options.index The index of the nomination among the
     * others.
     * @returns {undefined}
     */
    function createNomination({ $h3, index }) {
      // Get edit links. It has to be an edit link, and not an article link,
      // because it has to point to the nomination page, not the article.
      const $editLinks = $h3.find('.mw-editsection a');
      const useParentDiv = $editLinks.length === 0;
      
      const parentDiv = $h3.parent();
      const $editLinks2 = parentDiv.find('.mw-editsection a');
      const $editLinksOption = useParentDiv ? $editLinks2 : $editLinks;
  
      // There are no edit links.
      if ($editLinksOption.length === 0) {
        return;
      }
  
      const titleRegex = /[&?]title=(.*?)(?:&|$)/;
  
      // Find the edit link that matches our regex.
      const $filteredEditLinks = $editLinksOption.filter((elementIndex, element) =>
        $(element)
          .attr('href')
          .match(titleRegex)
      );
  
      // Only continue if there are filtered edit links. They won't appear when a
      // Peer Review is "too long" and therefore is replaced with a message to go
      // to the review page directly. So, skip this nomination.
      if (
        $filteredEditLinks.length === 0 ||
        !$filteredEditLinks.eq(0).attr('href') ||
        !$filteredEditLinks
          .eq(0)
          .attr('href')
          .match(titleRegex)
      ) {
        return;
      }
  
      // Get the name of the nomination page.
      const pageName = decodeURIComponent(
        $filteredEditLinks
          .eq(0)
          .attr('href')
          .match(titleRegex)[1]
      );
  
      // Create the [show] / [hide] link.
      const showHideLink = createShowHideLink(index);
  
      // Create the spot to put the data that we will retrieve via the Wikipedia
      // API.
      const newSpan = createData(pageName);
  
      // Move the nomination into a hidden node.
      hideNomination($h3, index);
  
      // Add placeholders for the data that we will retrieve for the API.
      addNominationData(pageName, newSpan, NominationsViewer.nominationData);
      
      const nodeToReplace = useParentDiv ? parentDiv : $h3;
  
      // Create the nomination's title line.
      const newNode = createNewNode({
        oldNode: nodeToReplace,
        showHideLink,
        newSpan,
        index,
      });
  
      // Create the actual nomination
      const nomDiv = generateNomination(index, newNode, nodeToReplace);
  
      // Replace this nomination with the new one we created.
      nodeToReplace.replaceWith(nomDiv);
  
      // Ask the API to add data to our placeholders.
      addAllNomData(pageName);
    }
  
    function createShowHideLink(index) {
      const span = $('<span class="nomv-show-hide"></span>');
      const link = $(`<a href="#" id="nom-button-${index}">show</a>`).on(
        'click',
        { index },
        toggleNomClick
      );
  
      return span
        .append('[')
        .append(link)
        .append(']');
    }
  
    function generateNomination(index, newNode, oldNode) {
      return $(`<div class="nomination" id="nom-${index}"></div>`)
        .append(newNode.clone(true))
        .append($(oldNode[0].nextSibling).clone(true));
    }
  
    // This function MUST stay in JavaScript, rather than switch to jQuery, for
    // optmization reasons.
    //
    // The jQuery version slowed the page down by about 28%. This version slows
    // the page down by about 11%, so it is about 17% faster.
    function hideNomination($h3, index) {
      // Re-create all nodes between this H3 node, and the next one, then place it
      // into a new node.
      const hiddenNode = document.createElement('div');
  
      hiddenNode.className = 'nomination-body';
      hiddenNode.id = `nom-data-${index}`;
      hiddenNode.style.display = 'none';
      
      let parentNode = $h3[0].parentNode;
      let sectionStart = parentNode.classList.contains('mw-heading3') ? parentNode : $h3[0];
      
      let nomNextSibling = sectionStart.nextSibling;
  
      // Continue to the next node, as long as the next node still exists, it
      // isn't an H2 or H3, and it doesn't have the class "printfooter or mw-heading2"
      while (
        nomNextSibling &&
         !(
		   ['H2', 'H3'].includes(nomNextSibling.nodeName) || 
		   (
		    nomNextSibling.childNodes &&      	
		    nomNextSibling.childNodes.length > 1 &&
		    ['H2', 'H3'].includes(nomNextSibling.childNodes[1].nodeName)
		   )
		  ) &&
        !(
          nomNextSibling.classList &&
          nomNextSibling.classList.contains('printfooter')
        ) &&
        !(
          nomNextSibling.classList &&
          nomNextSibling.classList.contains('mw-heading2')
        ) &&
        !(
          nomNextSibling.classList &&
          nomNextSibling.classList.contains('mw-heading3')
        )
      ) {
        const nomNextSiblingTemporary = nomNextSibling.nextSibling;
  
        // Move the node, if it isn't a text node
        if (nomNextSibling.nodeType !== 3) {
          // eslint-disable-next-line unicorn/prefer-node-append
          hiddenNode.appendChild(nomNextSibling);
        }
  
        nomNextSibling = nomNextSiblingTemporary;
      }
  
      // Insert hidden content
      return sectionStart.after(hiddenNode);
    }
  
    /**
     * The main function, to run the script.
     *
     * @returns {undefined}
     */
    function init() {
      let currentPageIsASubpage;
      let currentPageIsEnabled;
      const pageName = mw.config.get('wgPageName');
  
      // Check if enabled on this page
      Object.keys(NominationsViewer.enabledPages).forEach((page) => {
        if (pageName === page.replace(/\s/g, '_')) {
          currentPageIsEnabled = true;
        } else if (pageName.startsWith(page.replace(/\s/g, '_'))) {
          currentPageIsASubpage = true;
        }
      });
  
      if (
        !currentPageIsEnabled ||
        mw.config.get('wgAction') !== 'view' ||
        window.location.href.includes('&oldid=') ||
        currentPageIsASubpage
      ) {
        return;
      }
  
      // Append the CSS now, since we're definitely running the script on this
      // page.
      addCss();
  
      const $parentNode = $('.mw-content-ltr');
      const $h3s = $parentNode.find('h3');
  
      addAllNomInfo($h3s);
  
      // Loop through each nomination
      $h3s.each((index, element) =>
        createNomination({
          $h3: $(element),
          index,
        })
      );
  
      // Fix any conflicts with collapsed comments (using the special template).
      $('.collapseButton').each((index, element) => {
        const $link = $(element)
          .children()
          .first();
  
        // eslint-disable-next-line unicorn/prefer-string-slice
        const newIndex = $link
          .attr('id')
          .substring(
            $link.attr('id').indexOf('collapseButton') + 'collapseButton'.length,
            $link.attr('id').length
          );
  
        $link.attr('href', '#').on('click', { newIndex }, collapseTable);
      });
    }
  
    // Helpers
    function collapseTable(event) {
      event.preventDefault();
  
      const tableIndex = event.data.index;
      const collapseCaption = 'hide';
      const expandCaption = 'show';
  
      const $button = $(`#collapseButton${tableIndex}`);
      const $table = $(`#collapsibleTable${tableIndex}`);
  
      if ($table.length === 0 || $button.length === 0) {
        return false;
      }
  
      const $rows = $table.find('> tbody > tr');
  
      if ($button.text() === collapseCaption) {
        $rows.each((index, element) => {
          if (index === 0) {
            return true;
          }
  
          return $(element).hide();
        });
  
        return $button.text(expandCaption);
      }
  
      $rows.each((index, element) => {
        if (index === 0) {
          return true;
        }
  
        return $(element).show();
      });
  
      return $button.text(collapseCaption);
    }
  
    // Add CSS to the page, to use for this script. This is a separate function,
    // so that it's more easy to disable it when necessary.
    function addCss() {
      mw.util.addCSS(`
  #content .nomination h3 {
    margin-bottom: 0;
    padding-top: 0;
  }
  
  .nomination-data,
  .nomination-order,
  .overall-controls {
    font-size: 75%;
    font-weight: normal;
  }
  
  .nomination-order {
    display: inline-block;
    width: 25px;
  }
  
  .nomv-show-hide {
    display: inline-block;
    font-size: 13px;
    font-weight: normal;
    margin-right: 2.5px;
    width: 40px;
  }
  
  .nomv-show-hide a {
    display: inline-block;
    text-align: center;
    width: 31px;
  }
  
  .nomv-data::before {
    content: " · ";
  }
  
  .nomv-data abbr {
    white-space: nowrap;
  }
  `);
    }
  
    function expandAllNoms(event) {
      return toggleAllNoms(event, 'expand');
    }
  
    function collapseAllNoms(event) {
      return toggleAllNoms(event, 'collapse');
    }
  
    function toggleAllNoms(event, actionParam) {
      let action = actionParam;
  
      if (!action) {
        action = 'expand';
      }
  
      event.preventDefault();
  
      const { allH3Length } = event.data;
  
      new Array(allH3Length).fill().forEach((value, index) => {
        toggleNom(index, action);
      });
    }
  
    function toggleNom(id, actionParam) {
      let action = actionParam;
  
      if (!action) {
        action = '';
      }
  
      const toggleHideNom = ($node, $nomButton) => {
        $node.hide();
  
        return $nomButton.text('show');
      };
  
      const toggleShowNom = ($node, $nomButton) => {
        $node.show();
  
        return $nomButton.text('hide');
      };
  
      const $node = $(`#nom-data-${id}`);
      const $nomButton = $(`#nom-button-${id}`);
  
      // These are actions that override the status for all nominations.
      if (action === 'collapse') {
        return toggleHideNom($node, $nomButton);
      }
  
      if (action === 'expand') {
        return toggleShowNom($node, $nomButton);
      }
  
      // These have to be separate from the above because they have a lower
      // priority.
      if ($node.is(':visible')) {
        return toggleHideNom($node, $nomButton);
      }
  
      if ($node.is(':hidden')) {
        return toggleShowNom($node, $nomButton);
      }
  
      return null;
    }
  
    function toggleNomClick(event) {
      event.preventDefault();
  
      const { index } = event.data;
  
      return toggleNom(index);
    }
  
    // Callbacks
    function addParticipants(revisions, pageName, queryContinue) {
      if (!dataIsEnabled('participants') || !revisions) {
        return;
      }
  
      const users = {};
      let userCount = 0;
  
      revisions.forEach((revision) => {
        if (!revision.user) {
          return;
        }
  
        if (users[revision.user]) {
          users[revision.user] += 1;
        } else {
          users[revision.user] = 1;
          userCount += 1;
        }
      });
  
      const moreThan = queryContinue ? 'more than ' : '';
      const usersArray = Object.keys(users).map((user) => [
        user,
        parseInt(users[user], 10),
      ]);
  
      const usersArray2 = [...usersArray]
        .sort((a, b) => {
          if (a[1] < b[1]) {
            return 1;
          }
  
          if (a[1] > b[1]) {
            return -1;
          }
  
          return 0;
        })
        .map((user) => `${user[0]}: ${user[1]}`);
  
      addNewNomData({
        pageName,
        data: `${moreThan + userCount} ${pluralize('participant', userCount)}`,
        id: 'participants',
        hoverText: `Sorted from most to least edits&#10;Total edits: ${
          revisions.length
        }&#10;Format: &quot;editor: \
  number of edits&quot;:&#10;&#10;${usersArray2.join('&#10;')}`,
      });
    }
  
    function allRevisionsCallback(object) {
      const vars = formatJSON(object);
  
      if (!vars) {
        return;
      }
  
      // Participants
      addParticipants(vars.revisions, vars.pageName, object['query-continue']);
  
      // Nomination age
      addAge(vars.firstRevision, vars.pageName);

      // Last edit
      addLastEdit(vars.lastRevision, vars.pageName);
    }
  
    function addImagesCount(content, pageName) {
      if (!nomType('pictures') || !dataIsEnabled('images')) {
        return;
      }
  
      // Determine number of images in the nomination
      const pattern1 = /\[\[(file|image):.*?]]/gi;
      const pattern2 = /\n(file|image):.*\|/gi;
      const matches1 = content.match(pattern1);
      const matches2 = content.match(pattern2);
  
      const matches = matches1 || matches2 || [];
      const images = matches.map((match) => {
        const split = match.split('|');
        const filename = $.trim(split[0].replace(/^\[\[/, ''));
  
        return filename;
      });
  
      addNewNomData({
        pageName,
        data: `${matches.length} ${pluralize('image', matches.length)}`,
        id: 'images',
        hoverText: `Images (in order of appearance):&#10;&#10;${images.join(
          '&#10;'
        )}`,
      });
    }
  
    function getNominators(content) {
      let nomTypeText = '';
      let listOfNominators = {};
  
      switch (nomType()) {
        case 'nominations':
          nomTypeText = 'nominator';
          listOfNominators = findNominators(content, /Nominator(\(s\))?:.*/);
  
          // No nominators were found, so try once more with a different pattern.
          if ($.isEmptyObject(listOfNominators)) {
            listOfNominators = findNominators(content, /:<small>''.*/);
          }
  
          break;
        case 'reviews':
          nomTypeText = 'notification';
          listOfNominators = findNominators(content, /(Notified|Notifying):?.*/);
  
          break;
        case 'pictures':
          nomTypeText = 'nominator';
          listOfNominators = findNominators(
            content,
            /\* '''Support as nominator''' – .*/
          );
  
          break;
        default:
      }
  
      return { listOfNominators, nomTypeText };
    }
  
    function addNominators(content, pageName) {
      if (!dataIsEnabled('nominators') || nomType('peer reviews')) {
        return;
      }
  
      const { listOfNominators, nomTypeText } = getNominators(content);
      let allNominators = Object.keys(listOfNominators)
        .map((n) => n)
        .sort();
  
      let data;
  
      if (allNominators.length > 0) {
        data = `${allNominators.length} ${pluralize(
          nomTypeText,
          allNominators.length
        )}`;
  
        // We couldn't identify any nominators.
      } else {
        // Use the first username on the page to determine the nominator.
        const matches = content.match(/\[\[User:(.*?)[\]|]/);
  
        if (nomType('nominations') && matches) {
          allNominators = [matches[1]];
          data = `${allNominators.length} ${pluralize(
            nomTypeText,
            allNominators.length
          )}`;
  
          // This is not a nomination-type, and we couldn't find any relevant
          // users, so we have to assume that there are none.
        } else {
          data = `0 ${pluralize(nomTypeText, 0)}`;
        }
      }
  
      addNewNomData({
        pageName,
        data,
        id: 'nominators',
        hoverText: `${pluralize(
          capitalize(nomTypeText),
          allNominators.length
        )} (sorted alphabetically):&#10;&#10;${allNominators.join('&#10;')}`,
      });
    }
  
    /**
     * Generate the patterns used to find vote text.
     *
     * @returns {Object} The patterns.
     */
    function getVoteTextAndPatterns() {
      // Look for text that is enclosed within bold text, or level-4 (or greater)
      // headings.
      const wrapPattern = "('''|====)";
  
      // The amount of characters allowed between the vote text, and the wrapping
      // patterns.
      const voteBuffer = 25;
      const textPattern = `(.{0,${voteBuffer}})?`;
  
      let openPattern = `${wrapPattern}${textPattern}`;
      let closePattern = `${textPattern}${wrapPattern}`;
  
      let supportText = 'support';
      let opposeText = 'oppose';
  
      // Use different words for review pages.
      if (nomType('reviews')) {
        supportText = 'keep';
        opposeText = 'delist';
  
        // Pictures has their own specific method of declaring votes.
      } else if (nomType('pictures')) {
        openPattern = "\\*(\\s)?'''.*?";
        closePattern = ".*?'''";
      }
  
      const createPattern = (text) =>
        new RegExp(
          `(${openPattern}${text}${closePattern}|^;${textPattern}${text})`,
          'gim'
        );
  
      return {
        supportText,
        supportPattern: createPattern(supportText),
        opposeText,
        opposePattern: createPattern(opposeText),
      };
    }
  
    function shouldShowVotes() {
      const showOpposesForNominations = false;
      const showOpposesForReviews = true;
  
      return (
        ((nomType('nominations') || nomType('pictures')) &&
          showOpposesForNominations) ||
        (nomType('reviews') && showOpposesForReviews)
      );
    }
  
    /**
     * Add votes data to a nomination.
     *
     * @param {string} content The nomination's content.
     * @param {string} pageName The page name.
     * @returns {undefined}
     */
    function addVotes(content, pageName) {
      if (!dataIsEnabled('votes') || nomType('peer reviews')) {
        return;
      }
  
      const {
        supportText,
        supportPattern,
        opposeText,
        opposePattern,
      } = getVoteTextAndPatterns();
  
      const supportMatches = content.match(supportPattern) || [];
      const opposeMatches = content.match(opposePattern) || [];
  
      const supports = `${supportMatches.length} ${pluralize(
        supportText,
        supportMatches.length
      )}`;
      const opposes = `, ${opposeMatches.length} ${pluralize(
        opposeText,
        opposeMatches.length
      )}`;
  
      addNewNomData({
        pageName,
        data: shouldShowVotes() ? supports + opposes : supports,
        id: 'votes',
        hoverText: supports + opposes,
      });
    }
  
    function currentRevisionCallback(object) {
      const vars = formatJSON(object);
  
      if (!vars) {
        return;
      }
  
      const content = vars.firstRevision ? vars.firstRevision['*'] : null;
  
      if (!content) {
        return;
      }
  
      // 'images'
      addImagesCount(content, vars.pageName);
  
      // 'nominators'
      addNominators(content, vars.pageName);
  
      // 'votes'
      addVotes(content, vars.pageName);
    }
  
    function addAge(firstRevision, pageName) {
      if (!dataIsEnabled('age') || !firstRevision) {
        return;
      }
  
      const { timeAgo, then } = getTimeAgo(firstRevision.timestamp);
  
      addNewNomData({
        pageName,
        data: timeAgo,
        id: 'age',
        hoverText: `Creation date (local time):&#10;&#10;${then}`,
      });
    }

    function addLastEdit(lastRevision, pageName) {
        if (!dataIsEnabled('lastedit') || !lastRevision) {
          return;
        }
    
        const { timeAgo, then } = getActivity(lastRevision.timestamp);
    
        addNewNomData({
          pageName,
          data: timeAgo,
          id: 'lastedit',
          hoverText: `Last edit date (local time):&#10;&#10;${then}`,
        });
    }
  
    // Callback helpers
    function capitalize(string) {
      return string.charAt(0).toUpperCase() + string.slice(1);
    }
  
    /**
     * Check if the data field is enabled.
     *
     * @param {string} dataName The name of the data field to look up.
     * @returns {boolean} The data field is enabled, so we want to use it.
     */
    function dataIsEnabled(dataName) {
      return NominationsViewer.nominationData.some((data) => dataName === data);
    }
  
    // Given `content`, find nominators with the `pattern`. Returns an Object, so
    // that we exclude duplicates.
    function findNominators(content, pattern) {
      const nominatorMatches = content.match(pattern);
      const listOfNominators = {};
  
      if (!nominatorMatches) {
        return listOfNominators;
      }
  
      // Find nominator usernames.
      // [[User:Example|Example]], [[Wikipedia talk:WikiProject Example]]
      let nominators = nominatorMatches[0].match(
        /\[\[(user|wikipedia|wp|wt)([ _]talk)?:.*?]]/gi
      );
  
      if (nominators) {
        nominators.forEach((nominator) => {
          // Strip unneeded characters from the nominator's URL.
          let username = nominator
            // Strip the start of the username link.
            .replace(/\[\[(user|wikipedia|wp|wt)([ _]talk)?:/i, '')
            // Strip the displayed portion of the username link.
            .replace(/\|.*/, '')
            // Strip the ending portion of the username link.
            .replace(']]', '')
            // Strip URL anchors.
            .replace(/#.*?$/, '');
  
          // Does 'username' have a '/' that we have to strip?
          if (username.includes('/')) {
            username = username.slice(0, Math.max(0, username.indexOf('/')));
          }
  
          listOfNominators[username] += 1;
        });
      }
  
      // {{user|Example}} and similar variants
      const userTemplatePattern = /{{user.*?\|(.*?)}}/gi;
  
      nominators = nominatorMatches[0].match(userTemplatePattern);
  
      if (nominators) {
        nominators.forEach((singleNominator) => {
          listOfNominators[
            singleNominator.replace(userTemplatePattern, '$1')
          ] += 1;
        });
      }
  
      return listOfNominators;
    }
  
    function formatJSON(object) {
      if (!object.query || !object.query.pages) {
        return false;
      }
  
      const vars = [];
  
      vars.pages = object.query.pages;
      vars.page = Object.keys(vars.pages).map((page) => page);
  
      if (vars.page.length !== 1) {
        return false;
      }
  
      vars.page = object.query.pages[vars.page[0]];
      vars.pageName = vars.page.title.replace(/\s/g, '_');
  
      if (!vars.page.revisions) {
        return false;
      }
  
      [vars.firstRevision] = vars.page.revisions;
      [vars.lastRevision] = vars.page.revisions.slice(-1);      
      vars.revisions = vars.page.revisions;
  
      return vars;
    }
  
    /**
     * Check if the nomination type of the current nomination is the type
     * specified. If no type is specified, then return the type of the current
     * nomination. Possible types are: `nominations`, `peer reviews`, `pictures`,
     * and `reviews`, as specified in `NominationsViewer.enabledPages`.
     *
     * @param {string} [type] The type to compare the current nomination with.
     * @returns {boolean|string} The current nomination matches the type
     * specified, or the type of the current nomination.
     */
    function nomType(type = null) {
      const pageName = mw.config.get('wgPageName').replace(/_/g, ' ');
      const pageType = NominationsViewer.enabledPages[pageName];
  
      if (type) {
        return type === pageType;
      }
  
      return pageType;
    }
  
    /**
     * Pluralize a word if necessary.
     *
     * @param {string} string The word to possibly pluralize.
     * @param {number} count The number of items there are.
     * @returns {string} The pluralized word.
     */
    function pluralize(string, count) {
      const plural = `${string}s`;
  
      if (count === 1) {
        return string;
      }
  
      return plural;
    }
  
    /**
     * Format a page name by remove any non-word characters.
     *
     * @param {string} pageName The page name to format.
     * @returns {string} The formatted page name.
     */
    function simplifyPageName(pageName) {
      return pageName.replace(/\W/g, '');
    }
  
    /**
     * Given a timestamp, generally calculate the time ago.
     *
     * @param {string} timestamp A timestamp.
     * @returns {Object.<string, string>} The time ago phrase.
     */
    function getTimeAgo(timestamp) {
      const matches = timestamp.match(
        /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z/
      );
      const now = new Date();
      const then = new Date(
        Date.UTC(
          matches[1],
          matches[2] - 1,
          matches[3],
          matches[4],
          matches[5],
          matches[6]
        )
      );
  
      const millisecondsAgo = now.getTime() - then.getTime();
      const daysAgo = Math.floor(millisecondsAgo / (1000 * 60 * 60 * 24));
      let timeAgo = '';
  
      if (daysAgo > 0) {
        const weeksAgo = Math.round(daysAgo / 7);
        const monthsAgo = Math.round(daysAgo / 30);
        const yearsAgo = Math.round(daysAgo / 365);
  
        if (yearsAgo >= 1) {
          timeAgo = `${yearsAgo} ${pluralize('year', yearsAgo)} old`;
        } else if (monthsAgo >= 3) {
          timeAgo = `${monthsAgo} ${pluralize('month', monthsAgo)} old`;
        } else if (weeksAgo >= 1) {
          timeAgo = `${weeksAgo} ${pluralize('week', weeksAgo)} old`;
        } else {
          timeAgo = `${daysAgo} ${pluralize('day', daysAgo)} old`;
        }
      } else {
        timeAgo = 'today';
      }
  
      return { timeAgo, then };
    }

    function getActivity(timestamp) {
        const matches = timestamp.match(
          /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z/
        );
        const now = new Date();
        const then = new Date(
          Date.UTC(
            matches[1],
            matches[2] - 1,
            matches[3],
            matches[4],
            matches[5],
            matches[6]
          )
        );
    
        const millisecondsAgo = now.getTime() - then.getTime();
        const daysAgo = Math.floor(millisecondsAgo / (1000 * 60 * 60 * 24));
        let timeAgo = '';
    
        if (daysAgo > 0) {
          const weeksAgo = Math.round(daysAgo / 7);
          const monthsAgo = Math.round(daysAgo / 30);
          const yearsAgo = Math.round(daysAgo / 365);
    
          if (yearsAgo >= 1) {
            timeAgo = `<b>Inactive for ${yearsAgo} ${pluralize('year', yearsAgo)}</b>`;
          } else if (monthsAgo >= 3) {
            timeAgo = `<b>Inactive for ${monthsAgo} ${pluralize('month', monthsAgo)}</b>`;
          } else if (weeksAgo >= 1) {
            timeAgo = `<b>Inactive for ${weeksAgo} ${pluralize('week', weeksAgo)}</b>`;
          } else {
            timeAgo = `Active ${daysAgo} ${pluralize('day', daysAgo)} ago`;
          }
        } else {
          timeAgo = 'Active today';
        }
    
        return { timeAgo, then };
      }
  
    init();
  });
  // </nowiki>