Fandom Developers Wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * BlogLink/code.js
 * @file Adds links to user's blog and activity pages to global dropdown nav
 * @author Eizen <dev.wikia.com/wiki/User_talk:Eizen>
 * @license CC-BY-SA 3.0
 * @external "mediawiki.api"
 * @external "mediawiki.util"
 */

/* jshint -W030, undef: true, unused: true, eqnull: true, laxbreak: true */

;(function (module, window, $, mw) {
  "use strict";

  // Prevent double loads and respect prior double load check formatting
  if (!window || !$ || !mw || module.isLoaded || window.isBlogLinkLoaded) {
    return;
  }
  module.isLoaded = true;

  // Protected pseudo-enums
  Object.defineProperties(this, {

    /**
     * @description This pseudo-enum contains a pair of <code>string</code>
     * <code>object</code> properties containing the names of various element
     * selectors targeted by the script's various methods or applied to the new
     * user dropdown tabs added by the script. The <code>NAMES</code> property
     * contains the names of selectors added to elements generated by the script
     * itself, while the <code>TARGETS</code> property contains jQuery-friendly
     * formatted selectors that are targeted for the retrieval of their text
     * content or used as parent nodes to which is added script-constructed
     * child HTML.
     *
     * @readonly
     * @enum {object}
     */
    Selectors: {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        NAMES: Object.freeze({
          CLASS_DROPDOWN_LI: "user-panel__list-item",
          DIR_DROPDOWN_LI: "auto",
          ID_DROPDOWN: "bl-"
        }),
        TARGETS: Object.freeze({
          NAVIGATION_PANEL: "#user-panel__content .navigation-panel",
          NAVIGATION_CONTRIBS: "a[data-label='account.contributions']",
          DEFAULT_PLACEMENT: "a[data-label='account.preferences']"
        }),
      }),
    },

    /**
     * @description The <code>Config</code> pseudo-enum is used primarily by
     * <code>this.validateConfig</code> to ensure that the user's input config
     * (if applicable) is well-formed and properly defined prior to its usage
     * by the script. If the user has chosen not to include certain properties
     * in the config object, the default values established in this enum are
     * applied instead as default values. The enum contains two total data
     * <code>object</code>s establishing both the formal name of the property as
     * it exists in the config object and its associated default value.
     *
     * @readonly
     * @enum {object}
     */
    Config: {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        SHOW_CONTRIBS: Object.freeze({
          NAME: "contribs",
          DEFAULT: true,
        }),
        SHOW_ACTIVITY: Object.freeze({
          NAME: "activity",
          DEFAULT: true,
        }),
      }),
    },

    /**
     * @description The <code>Dependencies</code> pseudo-enum contains a pair of
     * <code>string</code> arrays as properties. The <code>GLOBALS</code> array
     * contains the various <code>wg</code> globals that are fetched and cached
     * via a <code>mw.config.get</code> invocation later in the application,
     * while the <code>MODULES</code> array contains the names of ResourceLoader
     * modules that are loaded via <code>mw.config.get</code> at the start of
     * the program's execution.
     *
     * @readonly
     * @enum {object}
     */
    Dependencies: {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        GLOBALS: Object.freeze([
          "wgUserLanguage",
          "wgUserName"
        ]),
        MODULES: Object.freeze([
          "mediawiki.api",
          "mediawiki.util"
        ]),
      })
    },

    /**
     * @description The <code>Tabs</code> pseudo-enum contains a pair of
     * <code>string</code> arrays as properties that refer to the two possible
     * nav tabs that can be added to the dropdown, namely "blog" and "user
     * activity." Each object has a message property with a value corresponding
     * to the internal name of the system message referring to that message's
     * text and an address property that indicates where the associated link
     * should point.
     *
     * @readonly
     * @enum {object}
     */
    Tabs: {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        BLOG: Object.freeze({
          MESSAGE: "userprofile-userprofilenavigation-link-user-blog",
          ADDRESS: "User_blog:$1"
        }),
        ACTIVITY: Object.freeze({
          MESSAGE: "userprofile-userprofilenavigation-link-activity",
          ADDRESS: "Special:UserProfileActivity/$1"
        }),
      })
    },

    /**
     * @description The <code>Utility</code> pseudo-enum houses assorted
     * constants of various data types used through the program for various
     * purposes. It contains the <code>setInterval</code> at which the script
     * scans the page for the user nav dropdown, a <code>boolean</code> flag for
     * debug mode, and several <code>string</code> related to the script name
     * and the related <code>mw.hook</code> event name.
     *
     * @readonly
     * @enum {number|boolean|string}
     */
    Utility: {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        CHECK_RATE: 300,
        DEBUG: false,
        SCRIPT: "BlogLink",
        HOOK_NAME: "dev.blogLink",
        LS_PREFIX: "BlogLink-cache-messages-",
      }),
    },
  });

  /**
   * @description As the name implies, the <code>assembleUserTab</code> method
   * is used to build the custom user tabs linking to the user's blog list (and
   * user activity page, if applicable). These are attached to the nav dropdown
   * once the user clicks the profile circle icon (everything is lazy-loaded
   * now, apparently).
   *
   * @param {string} paramText - <code>string</code> message text to display
   * @param {string} paramIDName - <code>string</code> to use in forming IDs
   * @param {string} paramAddress - The <code>href</code> value for link
   * @returns {string} - Formatted <code>string</code> HTML
   */
  this.assembleUserTab = function (paramText, paramIDName, paramAddress) {
    return mw.html.element("li", {
      "class": this.Selectors.NAMES.CLASS_DROPDOWN_LI,
      "id": this.Selectors.NAMES.ID_DROPDOWN + paramIDName + "-li",
      "dir": this.Selectors.NAMES.DIR_DROPDOWN_LI
    }, new mw.html.Raw(
      mw.html.element("a", {
        "id": this.Selectors.NAMES.ID_DROPDOWN + paramIDName + "-a",
        "href": mw.util.getUrl(paramAddress.replace("$1",
            this.info.globals.wgUserName)),
        "title": paramText
      }, paramText)
    ));
  };

  /**
   * @description This helper function, based on MassEdit's assorted validation
   * methods, is used to ensure that the user's inputted config has properties
   * of the proper data type, i.e. <code>boolean</code> for the two flags
   * denoting the user's preferences. If no property exists or if the wrong data
   * type is detected, the default value specified in <code>this.Config</code>
   * is applied instead.
   *
   * @param {object} paramConfig - User config <code>object</code> to validate
   * @returns {object} config - Frozen well-formed config <code>object</code>
   */
  this.validateConfig = function (paramConfig) {

    // Declarations
    var element, entry, fields, config;

    // Definitions
    config = {};
    fields = this.Config;

    // Set to default if user input doesn't exist or if wrong data type
    for (element in fields) {
      if (!fields.hasOwnProperty(element)) {
        continue;
      }
      entry = fields[element];

      // Define with default if no property or if input is of wrong data type
      config[entry.NAME] = (!paramConfig.hasOwnProperty(entry.NAME) ||
        typeof paramConfig[entry.NAME] !== typeof entry.DEFAULT)
          ? entry.DEFAULT
          : paramConfig[entry.NAME];
    }

    return Object.freeze(config);
  };

  /**
   * @description The <code>getMessage</code> method replaces the previous
   * script version's usage of the I18n-js external dependency in favor of
   * simply retrieving the latest versions/translations of the system message
   * via API query. This is done ia the invocation of
   * <code>mw.Api.prototype.getMessages</code>. For the purposes of simplicity,
   * the method itself sorts through the returned data <code>object</code> for
   * the desired "Blog" and "Activity" text and returns those
   * <code>string</code>s alone as an array wrapped in a new
   * <code>$.Deferred</code> promise.
   *
   * @param {Array<string>} paramMessages - <code>mw.message</code> name array
   * @returns {object} - Resolved/rejected <code>$.Deferred</code>
   */
  this.getMessages = function (paramMessages) {

    // Declarations
    var message, messages;

    return new mw.Api().getMessages(paramMessages, {
      amlang: this.info.globals.wgUserLanguage,
    }).then(function (paramData) {
      for (message in paramData) {
        if (paramData.hasOwnProperty(message)) {
          (messages = messages || []).push(paramData[message]);
        }
      }

      return messages;
    }.bind(this), window.console.error.bind(null, this.Utility.SCRIPT));
  };

  /**
   * @description This handler, borrowed from MassEdit, is used for returning
   * message data from storage and adding new data to storage for reuse.
   * <code>localStorage</code> is accessed safely via <code>mw.storage</code>
   * and placed within a <code>try...catch</code> block to handle any additional
   * thrown errors. A local object stored in <code>this.info</code> is used as
   * a fallback in the event of an error being thrown.
   *
   * @see <a href="https://git.io/JfrsN">Wikia's jquery.store.js (pre-UCP)</a>
   * @param {string} paramKey - Name of requested message
   * @param {string} paramValue- Content of message when setting (optional)
   * @returns {string|null} - Returns message content or <code>null</code>
   */
  this.queryStorage = function (paramKey, paramValue) {

    // Declarations
    var isSetting, lsKey, messages;

    // Sanitize parameter
    paramKey = paramKey.toLowerCase();

    // Handler can be used for both getting and setting, so check for which
    isSetting = (Array.prototype.slice.call(arguments).length == 2 &&
      paramValue != null);

    // Handle i18n for user language preference and uselang URL parameter
    lsKey = this.Utility.LS_PREFIX + this.info.globals.wgUserLanguage;

    // Get message data from localStorage
    try {
      messages = this.info.messages = JSON.parse(mw.storage.get(lsKey)) || {};
    } catch (paramError) {
      if (this.Utility.DEBUG) {
        window.console.error(paramError);
      }

      // Use fallback if localStorage throws
      messages = this.info.messages = this.info.messages || {};
    }

    // Return string HTML of requested message or explicit null
    if (!isSetting) {
      return (messages.hasOwnProperty(paramKey)) ? messages[paramKey] : null;
    }

    if (!messages.hasOwnProperty(paramKey)) {
      messages[paramKey] = paramValue;

      // Add to localStorage
      try {
        mw.storage.set(lsKey, JSON.stringify(messages));
      } catch (paramError) {
        if (this.Utility.DEBUG) {
          window.console.error(paramError);
        }
      }

      // Make sure new messages are added to localStorage
      if (this.Utility.DEBUG) {
        try {
          window.console.log("info.messages: ", this.info.messages);
          window.console.log("localStorage: ",
            JSON.parse(window.localStorage.getItem(lsKey)));
        } catch (paramError) {
          window.console.error(paramError);
        }
      }
    }

    return messages[paramKey];
  };

  /**
   * @description The <code>main</code> method is called once the script
   * initialization process handled by <code>init</code> has completed. This
   * method is used to load the latest version/translation of the requisite
   * <code>mw.message</code>s from the API, wait until the dropdown has loaded,
   * then assemble and add the custom blog/activity tab/navigation links to the
   * target element. <code>$.when</code> is used to concurrently coordinate the
   * loading of the message texts with the loading of the dropdown, the latter
   * of which makes use of <code>setInterval</code> and a helper
   * <code>$.Deferred</code> to ensure that subsequent program flow does not
   * occur until the dropdown is loaded.
   *
   * @returns {void}
   */
  this.main = function () {

    // Declarations
    var i, $helper, targets, target, tabs, tab, messages, message, addresses,
      $getEssentials, interval, needsMessages;

    // Definitions
    $helper = new $.Deferred();
    targets = this.Selectors.TARGETS;
    target = targets.NAVIGATION_PANEL;
    needsMessages = false;
    i = 0;

    if (this.Utility.DEBUG) {
      window.console.log("target:", target);
    }

    for (tab in this.Tabs) {
      if (!this.Tabs.hasOwnProperty(tab)) {
        continue;
      }

      // Translate enum member objects to arrays
      (tabs = tabs || []).push(tab.toLowerCase());
      (messages = messages || []).push(this.Tabs[tab].MESSAGE);
      (addresses = addresses || []).push(this.Tabs[tab].ADDRESS);

      // Get cached localStorage data for tab entry
      message = this.queryStorage(tabs[i]);

      if (this.Utility.DEBUG) {
        window.console.log("queryStorage(" + tabs[i] + "): ", message);
      }

      // If no localStorage data, set flag; otherwise add to messages arrays
      if (message == null) {
        needsMessages = true;
      } else {
        messages[i] = message;
      }
      i++;
    }

    // Make initial call to progress before interval in case dropdown exists
    $getEssentials = $.when(
      (needsMessages
        ? this.getMessages(messages)
        : new $.Deferred().resolve(messages).promise()),
      $helper.notify().promise()
    );

    // Continually check for presence of dropdown via setInterval
    interval = window.setInterval($helper.notify, this.Utility.CHECK_RATE);

    /**
     * @description The helper <code>$.Deferred</code> is pinged via the use of
     * <code>$.Deferred.notify<code> every time the script needs to check if
     * the targeted dropdown exists. Given that the new dropdown is lazy-loaded,
     * the callback is pinged every 200 ms by <code>setInterval</code> until the
     * dropdown joins the party. Once it exists, <code>$helper</code> is
     * resolved and execution continues to the <code>$.when</code> handler.
     */
    $helper.progress(function () {
      if (this.Utility.DEBUG) {
        window.console.log("$helper.progress");
      }

      // Check for target, or check if interval (hence UCP)
      if (!$(target).length) {
        return;
      } else if (interval) {
        window.clearInterval(interval);
      }

      // Resolve helper $.Deferred once masthead is found
      $helper.resolve();
    }.bind(this));

    /**
     * @description Once <code>$getEssentials</code> resolves or rejects, the
     * associated <code>then</code> handlers are invoked accordingly. If the
     * <code>$.when</code> <code>$.Deferred</code> is resolved, meaning that the
     * dropdown has been loaded and the latest translation of the system message
     * has been successfully retrieved, the Blog and Activity options are
     * constructed via <code>this.assembleTab</code> and appended to the target.
     */
    $getEssentials.then(function (paramMessages) {
      if (this.Utility.DEBUG) {
        window.console.log("paramMessages", paramMessages);
      }

      // Place BlogLink after contribs
      $($(targets.NAVIGATION_CONTRIBS).length
        ? targets.NAVIGATION_CONTRIBS
        : targets.DEFAULT_PLACEMENT
      ).parent().after(
        this.assembleUserTab(paramMessages[0], tabs[0], addresses[0])
      );

      // Place Activity tab before contribs if user-selected
      if (this.info.config.activity) {
        $(targets.DEFAULT_PLACEMENT).parent().after(
          this.assembleUserTab(paramMessages[1], tabs[1], addresses[1])
        );
      }

      // Hide contributions tab if option selected in user config
      if (!this.info.config.contribs) {
        $(target + " " + targets.NAVIGATION_CONTRIBS).parent().remove();
      }

      // Cache messages in localStorage
      if (needsMessages) {
        for (i = 0; i < paramMessages.length; i++) {
          this.queryStorage(tabs[i], paramMessages[i]);
        }
      }
    }.bind(this), window.console.error.bind(null, this.Utility.SCRIPT));
  };

  /**
   * @description The <code>init</code> method is called once the requisite
   * ResourceLoader modules have been loaded and is tasked with setting up the
   * script in preparation for the creation and insertion of the custom tabs by
   * <code>this.main</code>. The method performs the usual checks for duplicate
   * script loads and fetching/caching of required globals. The script exposes a
   * method called <code>observeScript</code> that displays the content of
   * <code>this</code>, the internal context object containing all the shared
   * state variables and assorted methods.
   *
   * @returns {void}
   */
  this.init = function () {

    // Object for storage of informational data
    this.info = {};

    // Validate any user config
    this.info.config = this.validateConfig(window.blogLinkConfig || {});

    if (this.Utility.DEBUG) {
      window.console.log("this.info.config", this.info.config);
    }

    // Fetch and cache wg globals
    this.info.globals = Object.freeze(mw.config.get(this.Dependencies.Globals));

    // Expose public methods for external debugging
    Object.defineProperty(module, "exports", {
      enumerable: true,
      writable: false,
      configurable: false,
      value: Object.freeze({
        observeScript: window.console.dir.bind(this, this),
      })
    });

    // Dispatch hook with window.dev.blogLink once init is complete
    mw.hook(this.Utility.HOOK_NAME).fire(module).add(this.main.bind(this));
  };

  // Coordinate loading of all relevant dependencies
  $.when(mw.loader.using(this.Dependencies.MODULES), $.ready)
    .done(this.init.bind(this))
    .fail(window.console.error.bind(null, this.Utility.SCRIPT));

}.call(Object.create(null), (this.dev = this.dev || {}).blogLink =
  this.dev.blogLink || {}, this, this.jQuery, this.mediaWiki));