This is a wiki for a reason. Anyone can contribute. If you see something that is inaccurate or can be improved, don't ask that it be fixed--just improve it.
[ Disclaimer, Create new user --- Wiki markup help, Install P99 ]

Difference between revisions of "MediaWiki:Common.js"

From Project 1999 Wiki
Jump to: navigation, search
 
(One intermediate revision by one user not shown)
Line 125: Line 125:
 
  */
 
  */
 
$(function () {
 
$(function () {
 +
  // Start onReady code
 +
 
   var hideDelay = 0;
 
   var hideDelay = 0;
 
   var trigDelay = 250;
 
   var trigDelay = 250;
Line 404: Line 406:
 
   };
 
   };
 
   addFashionSections();
 
   addFashionSections();
 
 
// Add a help link to dynamic zone lists, to explain how to fix them if they break
 
// NOTE: This could be better.  Potential improvements:
 
// A) we could only show the message when the table is actually (ie. every column except the first, of every row, is empty)
 
// B) instead of just showing an alert, we could show a confirm, and if they click ok we could send them to the zone's category page
 
// C) we could add logic to zone category pages to make them have a "History Mode" button, which converts all links to go straight to the history and open in a new tab
 
//    (this way people could actually fix the table by clicking every link, then closing the ones without recent edits ... we *could* even click every link for them ...)
 
try {
 
 
  const $mobsTable = $("#What\\.27s_in_this_zone")
 
    .parent()
 
    .next("p")
 
    .next("table");
 
 
 
 
  $mobsTable.after(
 
    '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
 
  );
 
 
  const $itemsTable = $mobsTable.next("div").next("p").next();
 
  $itemsTable.after(
 
    '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
 
  );
 
 
  $(".broken-dynamic-table-help").click(function(e){
 
    e.preventDefault();
 
    alert(
 
      "This table updates itself nightly with data from other mob/item pages.  If one of the pages contains even a single invalid character (eg. a smiley face or other Unicode character), it can break that update.\n\nUnfortunately, at this time the only fix is to click the zone's category link (at the bottom) and check the history of every page in it for recent edits, and then remove any special characters added recently ... and then wait 24 hours to see if it worked."
 
    );
 
  });
 
} catch (err) {
 
  1 + 1; /* Wed don't care; it probably was a page with no dynamic zone list */
 
}
 
  
  
Line 952: Line 919:
 
     );
 
     );
 
   }
 
   }
 +
 +
  // Dynamic zone page-related
 +
  // NOTE: Dynamic zone pages are used on every zone page in the site, to
 +
  //      display tables containing every mob/item in that zone.  They find the
 +
  //      pages for this list by checking the zone's wiki category.
 +
  //
 +
  //      However, at times an errant character in any one of the pages in the
 +
  //      category (eg. an editor pastes a Unicode character in) can break the
 +
  //      dynamic zone (PHP) code.
 +
  //
 +
  //      The code below first provides a link to explain the issue to regular
 +
  //      users, and then it adds a "secret" function for admins and power
 +
  //      users to more easily find recently edited pages (that could have
 +
  //      caused the problem).
 +
 +
  // Add a help link to dynamic zone lists, to explain how to fix them if they break
 +
  // NOTE: This could be better.  Potential improvements:
 +
  // A) we could only show the message when the table is actually (ie. every column except the first, of every row, is empty)
 +
  // B) instead of just showing an alert, we could show a confirm, and if they click ok we could send them to the zone's category page
 +
  // C) we could add logic to zone category pages to make them have a "History Mode" button, which converts all links to go straight to the history and open in a new tab
 +
  //    (this way people could actually fix the table by clicking every link, then closing the ones without recent edits ... we *could* even click every link for them ...)
 +
  //    EDIT: Actually, we have C) below now, but currently it's "secret", for
 +
  //          admins or power users, because if used en masse it could DOS the
 +
  //          wiki
 +
  try {
 +
 +
    const $mobsTable = $("#What\\.27s_in_this_zone")
 +
      .parent()
 +
      .next("p")
 +
      .next("table");
 +
 +
 +
 +
    $mobsTable.after(
 +
      '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
 +
    );
 +
 +
    const $itemsTable = $mobsTable.next("div").next("p").next();
 +
    $itemsTable.after(
 +
      '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
 +
    );
 +
 +
    $(".broken-dynamic-table-help").click(function(e){
 +
      e.preventDefault();
 +
      alert(
 +
        "This table updates itself nightly with data from other mob/item pages.  If one of the pages contains even a single invalid character (eg. a smiley face or other Unicode character), it can break that update.\n\nUnfortunately, at this time the only fix is to find the offending page and correct it (and then wait up to 24 hours to see if it worked).  However, wiki administrators (see /administrators) have tools that can help, so contacting one of them is recommended."
 +
      );
 +
    });
 +
  } catch (err) {
 +
    1 + 1; /* Wed don't care; it probably was a page with no dynamic zone list */
 +
  }
 +
 +
 +
  /**
 +
* Given HTML from a wiki history page, this function determines whether
 +
* that page contains edits from the last ${months} months.
 +
* @param html a string of HTML from a wiki entry's history page
 +
* @param months a number of months to check
 +
* @returns true if the page has recent edits
 +
*/
 +
function wasEditedRecently(html, months) {
 +
  var nMonthsAgo = new Date();
 +
  nMonthsAgo.setMonth(new Date().getMonth() - months);
 +
 +
  var dateString = $(html)
 +
    .find("#pagehistory li:first() .mw-changeslist-date")
 +
    .text();
 +
  var splitDate = dateString.split(" ");
 +
  var day = splitDate.at(-3);
 +
  var month = splitDate.at(-2);
 +
  var year = splitDate.at(-1);
 +
 +
  var newDateString = month + " " + day + "," + year;
 +
  var lastEditDate = new Date(Date.parse(newDateString));
 +
 +
  return lastEditDate > nMonthsAgo;
 +
}
 +
/**
 +
* With modern Javascript (async/await) iterating through a queue of fetches
 +
* with a delay is trivially easy ... but the wiki doesn't like modern
 +
* Javascript, so we need a recursive function, a custom promise, and a timeout,
 +
* just to accomplish that basic task.
 +
*
 +
* It's worth noting that the delay from the timeout is very important: without
 +
* it this function would flood the wiki with requests (potentially 100+ , all
 +
* at once).  Each fetch also needs to run sequentially (not in parallel, which
 +
* would be faster) for that same reason.
 +
*/
 +
function fetchPagesRecursivelyWithDelay(links, months) {
 +
  var url = links.pop();
 +
  return new Promise(function (resolve) {
 +
    fetch(url).then(function (response) {
 +
      return response.text().then(function (text) {
 +
        // Let user know how many more remain
 +
        $("#pagesLeftCount").text(links.length);
 +
 +
        if (wasEditedRecently(text, months)) {
 +
          var title = url
 +
            .substring(
 +
              "https://wiki.project1999.com/index.php?action=history&title="
 +
                .length
 +
            )
 +
            .replaceAll("_", " ");
 +
          $("#recentEdits").append(
 +
            '<li><a href="' + url + '" target="_blank">' + title + "</a></li>"
 +
          );
 +
        }
 +
 +
        if (links.length) {
 +
          // This handles the recursive case:
 +
          // resolve this recursion's promise ... after the next recursion
 +
          window.setTimeout(function () {
 +
            fetchPagesRecursivelyWithDelay(links, months).then(function () {
 +
              resolve();
 +
            });
 +
          }, 1000);
 +
        } else {
 +
          // This handles the final case
 +
          resolve();
 +
        }
 +
      });
 +
    });
 +
  });
 +
}
 +
 +
function addListToPageBottom(initialLinkCount) {
 +
  $("#mw-normal-catlinks")
 +
    .parent()
 +
    .before(
 +
      "<h1>Recent (<6 months) Edits</h1>" +
 +
        '<ul id="recentEdits" style="margin-bottom:2em"></ul>' +
 +
        '<div id="waitForIt">' +
 +
        "Please be patient!  Because we don't want to overload the wiki " +
 +
        "server, history is checked <em>slowly</em>.\n" +
 +
        'There are currently <span id="pagesLeftCount">' +
 +
        initialLinkCount +
 +
        "</span> pages remaining to check." +
 +
        "</div>"
 +
    );
 +
}
 +
 +
/**
 +
* Adds a new "debugging" section to the bottom of the current wiki page,
 +
* with an initially empty list.  The function then fetches the history
 +
* of every page in the current pages dynamic zone lists, one at a time.
 +
*
 +
* Each page is checked to see if they have been edited within the provided
 +
* number of months, and if so they are added to the list.
 +
*
 +
* Six is the recommended number of months to start, but the parameter can
 +
* be changed in case a page has sat broken for over half a year (and a
 +
* larger number will be needed to find the bad edit), or in case a page was
 +
* known to have broken more recently (in which case a lower number can shorten
 +
* the search).
 +
*
 +
* @param months
 +
* @usage From the console on a page with a broken dynamic zone table:
 +
*        checkForDynamicZonePagesEditedRecently(6);
 +
*
 +
*/
 +
window.checkForDynamicZonePagesEditedRecently = function (months) {
 +
  var links = $('th:contains("NPC Name"), th:contains("Item Name")')
 +
    .closest("table")
 +
    .children("tbody")
 +
    .children("tr")
 +
    .map(function (i, tr) {
 +
      var url = $(tr).children("td").eq(0).find("a").attr("href");
 +
      return (
 +
        "https://wiki.project1999.com/index.php?action=history&title=" +
 +
        url.split("/").at(-1)
 +
      );
 +
    })
 +
    .toArray();
 +
 +
  // Reverse the links, because the recursion reverses their order.  This isn't
 +
  // really *necessary*, but it looks odd to see the Z's appear before the A's
 +
  links.reverse();
 +
 +
  // Add the header and list, with wait message
 +
  addListToPageBottom(links.length);
 +
 +
  fetchPagesRecursivelyWithDelay(links, months).then(function () {
 +
    // Remove the wait message
 +
    $("#waitForIt").remove();
 +
  });
 +
}
 +
 +
 +
// End onReady code
 
});
 
});

Latest revision as of 19:58, 18 July 2025

/* Any JavaScript here will be loaded for all users on every page load. */

importScript('MediaWiki:Polyfills.js');

// HTTP prevents people from logging in now in (some?) Chrome browsers; redirect to HTTPS
try {
  var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
  var onHttp = location.protocol === 'http:';
  var haveNotTriedAlready = !location.search.includes('redirected_from_http');

  if (isChrome && onHttp && haveNotTriedAlready) {
    location =
      location.origin.replace('http:', 'https:') +
      location.pathname +
      location.search +
//      (location.search[0] === '?' ? '&' : '?') +
//      'redirected_from_http=1' +
      location.hash;
  }
} catch (err) {
  // Give up on trying to redirect
  // maybe alert user?
}

importScript('MediaWiki:Simple-lightbox.min.js');
importScript('MediaWiki:Zones.js');

// Get all magelos of current user
// (currently un-used, but intended for future achievement code, so that
//  we can have an "add this achievement to my magelo" drop-down with all
//  their magelos listed)
var getMagelosForCurrentUser = function () {
  var userName = $('#pt-userpage a').text();
  var url =
    'https://wiki.project1999.com/index.php?limit=50&tagfilter=&' +
    'title=Special%3AContributions&contribs=user&target=' +
    userName +
    '&namespace=500&topOnly=1&year=&month=-1';
  fetch(url)
    .then(function (response) {
      return response.text();
    })
    .then(function (html) {
      var mageloLinks = Array.from($(html).find('li a:contains("Magelo")'));
      var names = mageloLinks
        .filter(function (i, a) {
          return !$(a).text().includes('(');
        })
        .map(function (i, el) {
          return $(el).text();
        });
      console.log(names);
    });
};

// **********************************
// Add localStorage helper functions
// **********************************

/**
 * Basic "state getter": checks localStorage for *stateKey*, then return the
 * value for *pageName* (if any) found on that object.
 */
var getPageState = function (pageName, stateKey) {
  // Get the state for all pages for this key
  var allPageStates = JSON.parse(localStorage.getItem(stateKey) || '{}');

  // Extract just the state for this page (if any)
  return allPageStates[pageName];
};

/**
 * Returns the state data stored for the provided stateKey and the current page.
 * For instance, getStateByPage('checkbox-status') would return the checkbox\
 * status state for the current page.
 */
window.getStateByPage = function (stateKey) {
  var pageName = location.pathname.substr(1); // ditch the leading "/"
  return getPageState(pageName, stateKey);
};

/**
 * Return the global state for a provided state key
 * (works just like getStateByPage, but for states that don't care
 * about which page you are on)
 */
window.getGlobalState = function (stateKey) {
  return getPageState('__global', stateKey);
};

/**
 * Basic "state setter" function
 */
var setPageState = function (pageName, stateKey, state) {
  // Get the state for this key (for all pages) out of local storage
  var allPageStates = JSON.parse(localStorage.getItem(stateKey) || '{}');

  // Set the state only for this page
  allPageStates[pageName] = state;

  // Put the state for the stateKey (for all pages) back into local storage
  localStorage.setItem(stateKey, JSON.stringify(allPageStates));
};

/**
 * Sets the state data stored for the provided stateKey and the current page.
 * For instance, setStateByPage('checkbox-status', {a: 'b'}) would set the
 * checkbox status state for the current page to an object with a key of "a" and
 * value of "b".
 */
window.setStateByPage = function (stateKey, state) {
  var pageName = location.pathname.substr(1); // ditch the leading "/"
  setPageState(pageName, stateKey, state);
};

window.setGlobalState = function (stateKey, state) {
  return setPageState('__global', stateKey, state);
};

// *******************************

/* p1999wiki.js
 * written by http://wiki.project1999.com/User:Ravhin
 * last update: 7 January, 2018 by Loramin
 */
$(function () {
  // Start onReady code

  var hideDelay = 0;
  var trigDelay = 250;
  var hideTimer = null;
  var ajax = null;

  var currentPosition = { left: '0px', top: '0px' };

  // One instance that's reused to show info for the current person
  var container = $(
    '<div id="itemHoverContainer">' + '<div id="HoverContent"></div>' + '</div>'
  );

  $('body').append(container);

  /* --- hoverbox for item/mob, currently only used in magelo --- */

  // Determine which "a" elements should trigger the item stats mouseover
  var $mouseoverTargets = $('span.ih a');
  var isItemCategory =
    document.title.startsWith('Category:') &&
    document.title.includes('Equipment - Project 1999 Wiki') &&
    !document.title.includes('Worshiper Equipment');
  if (isItemCategory) {
    // Include the category's item list along with ".ih" links
    $mouseoverTargets = $mouseoverTargets.add('.mw-content-ltr a');
  }

  $mouseoverTargets = $mouseoverTargets.filter(function (i, a) {
    // Don't add hover to links like "next 200"
    return (
      !$(a).attr('href').startsWith('/Special:') &&
      !$(a).attr('href').includes('title=Category:')
    );
  });

  $mouseoverTargets.on('mouseover', function () {
    var $this = $(this);
    var itemname = $this.attr('title');

    if (itemname == '' || itemname == 'undefined') return;

    if (hideTimer) clearTimeout(hideTimer);

    if ($this.parents('div.mw-content-ltr').length) {
      var pos = $this.offset();
      var width = $this.width();

      container.css({
        left: pos.left + 5 + 'px',
        top: pos.top + 5 + 'px',
      });
    }

    $(this).trigger('mousemove');

    $('#itemHoverContent').html('&nbsp;');

    //$('#itemHoverContent').html('<div class="itemtopbg"><div class="itemtitle">Loading...</div></div>'
    //                          + '<div class="itembg" style="min-height:50px;"><div class="itemdata">'
    //                          + '<div class="itemicon" style="float:right;"><img alt="" src="/images/Ajax_loader.gif" border="0"></div>'
    //                          + '<p></p></div></div><div class="itembotbg"></div>');

    if (ajax) {
      ajax.abort();
      ajax = null;
    }
    ajax = $.ajax({
      url:
        window.location.protocol +
        '//wiki.project1999.com/index.php/Special:AjaxHoverHelper/' +
        itemname,
      cacheResponse: true,
      success: function (html) {
        var $html = $(html);
        $('#itemHoverContent')
          .html($html[2])
          .prepend($html[0])
          .prepend($html[1]);
      },
    });

    container.css('display', 'block');
    //container.fadeIn('fast');
  }); //on mouseover

  $('span.ih a').on('mouseout', function () {
    if (hideTimer) clearTimeout(hideTimer);
    hideTimer = setTimeout(function () {
      container.css('display', 'none');
      //container.fadeOut('fast');
    }, hideDelay);
  });

  $('span.ih a').mousemove(function (e) {
    var mousex = e.pageX + 20; //Get X coodrinates
    var mousey = e.pageY + 20; //Get Y coordinates
    var tipWidth = container.width(); //Find width of tooltip
    var tipHeight = container.height(); //Find height of tooltip

    //Distance of element from the right edge of viewport
    var tipVisX = $(window).width() - (mousex + tipWidth);
    //Distance of element from the bottom of viewport
    var tipVisY = $(window).height() - (mousey + tipHeight);

    if (tipVisX < 20) {
      //If tooltip exceeds the X coordinate of viewport

      if (tipWidth > e.pageX - 20) {
        mousex = 0;
      } else {
        mousex = e.pageX - tipWidth - 20;
      }
    }
    if (tipVisY < 20) {
      //If tooltip exceeds the Y coordinate of viewport
      mousey = e.pageY - tipHeight - 20;
    }

    container.css({ top: mousey, left: mousex });
  });

  // Allow mouse over of details without hiding details
  $('#itemHoverContainer').mouseover(function () {
    if (hideTimer) clearTimeout(hideTimer);
  });

  // Hide after mouseout
  $('#itemHoverContainer').mouseout(function () {
    if (hideTimer) clearTimeout(hideTimer);
    hideTimer = setTimeout(function () {
      container.css('display', 'none');
      //container.fadeOut('fast');
    }, hideDelay);
  });

  // magelo non-ajax item hover, but move box with mouse
  $('.magelohb').mousemove(function (e) {
    var childContainer = $(this).children('span.hb');

    var tipWidth = childContainer.width(); //Find width of tooltip
    var tipHeight = childContainer.height(); //Find height of tooltip

    var mousex = e.pageX + 20; //Get X coodrinates
    var mousey = e.pageY + 20; //Get Y coordinates

    //Distance of element from the right edge of viewport
    var tipVisX = $(window).width() - (mousex + tipWidth - 20);
    //Distance of element from the bottom of viewport
    var tipVisY = $(window).height() - (mousey + tipHeight - 20);

    if (tipVisX < 20) {
      //If tooltip exceeds the X coordinate of viewport

      if (tipWidth > e.pageX - 20) {
        mousex = 0;
      } else {
        mousex = e.pageX - tipWidth - 20;
      }
    }
    if (tipVisY < 20) {
      //If tooltip exceeds the Y coordinate of viewport
      mousey = e.pageY - tipHeight - 20;
    }

    childContainer.css({ top: mousey, left: mousex, 'z-index': '999' });
  });

  // change to position:fixed on all hover divs if we have JS active
  // otherwise leave as position:absolute so the stationary hovers are near their items
  $('.magelohb span.hb').each(function (i) {
    $(this).css({ position: 'fixed' });
  });

  // Chrome no longer displays alt text when an image is hovered over.  However the wiki only has
  // alt attributes for images, not titles.  This fixes that by converting alt => title
  $('img[alt]').each(function (i, img) {
    $(img).attr('title', $(img).attr('alt'));
  });

  // Fashion for item pages
  var extractFashionHtml = function (html) {
    return $(html)
      .find('.fashion_show, .primary_secondary_show')
      .map(function (i, el) {
        var $el = $(el);
        var data = $el.data();
        data.race = $el.find('.fashion_race').html();
        if ($el.is('.fashion_show')) {
          data.armor = $el.find('.fashion_armor').html();
        }
        return data;
      })
      .toArray();
  };

  var getFashionShows = function (fashionCategory) {
    var url = '/Category:Fashion: ' + fashionCategory;
    return $.get(url).then(extractFashionHtml);
  };

  var getItemPageShows = function () {
    var fashionCategories = $('#catlinks li a')
      .map(function (i, a) {
        return $(a).text();
      })
      .filter(function (i, text) {
        return text.startsWith('Fashion:');
      })
      .map(function (i, text) {
        return text.substr('Fashion: '.length);
      });
    var fashion = fashionCategories[0];
    if (!fashion) return $.when();

    var tint = fashionCategories[1];
    return getFashionShows(fashion).then(function (shows) {
      var sameTint = [];
      var otherTint = [];
      shows.forEach(function (show) {
        var isHeld = show.primaryFashion || show.secondaryFashion;
        var bothHaveSameTint = show.tint === tint;
        var bothHaveNoTint = !(show.tint || tint);
        if (isHeld || bothHaveSameTint || bothHaveNoTint) sameTint.push(show);
        else otherTint.push(show);
      });
      return {
        matches: sameTint,
        partialMatches: otherTint,
        fashion: fashion,
        tint: tint,
      };
    });
  };
  var addFashionSection = function (shows, isFullMatch) {
    var preface = isFullMatch
      ? '<h2>Fashion/Appearance</h2>'
      : '<div>Fashion images with the wrong "tint" (i.e. coloring) which ' +
        "can only be used to get a general sense of the item's " +
        'appearance:</div>';

    // Don't show the explanatory text if there are no shows to explain
    if (!shows.length && !isFullMatch) preface = '';

    var $ul = $('<ul>').append(
      shows.map(function (show) {
        var img = isFullMatch
          ? '<br/><img style="max-height: 100px; max-width: 100px;" src="/images/' +
            show.file.replace(/ /g, '_') +
            '"/>'
          : '';
        return (
          '<li class="fashion-link" data-file="' +
          show.file +
          '">' +
          '<a href="#">' +
          show.gender +
          ' ' +
          show.race +
          img +
          '</a></li>'
        );
      })
    );

    $('#itemfashion').before($('<div>' + preface + '</div>').append($ul));
  };

  var addFashionSections = function () {
    getItemPageShows().then(function (pageShows) {
      if (!pageShows) return;
      var matches = pageShows.matches || [];
      var partialMatches = pageShows.partialMatches || [];
      if (pageShows.matches.length || pageShows.partialMatches.length)
        addFashionSection(pageShows.matches, true);
      if (pageShows.partialMatches.length)
        addFashionSection(pageShows.partialMatches, false);
    });
  };
  addFashionSections();


  //*****************************************
  // Add class-based filtering to item tables
  //*****************************************w

  // Determine whether or not a provided row should be shown for the provided
  // class abbreviation (eg. "BRD")
  var isRowShown = function ($tr, classAbbrev) {
    // Extract classes (and races)
    var classText = $tr.find('td').eq(3).text().split('Class:')[1];
    if (!classText) return true; // Ignore (don't filter) rows without class text

    // remove "Race: " part (if any) and upper-case text
    classText = classText.split('Race:')[0] || classText;
    classText = classText.toUpperCase();

    // determine matching text
    var isMatch = classText.includes(classAbbrev);
    var isAllMatch = classText.includes('ALL');
    var isExcept = classText.includes('ALL EXCEPT');

    // determine matches
    if (isMatch && !isExcept) return true; // (eg. BRD in "BRD")
    if (!isMatch && isExcept) return true; // (eg. BRD in "ALL EXCEPT WIZ")
    if (!isExcept && isAllMatch) return true; // (eg. BRD in "ALL")
    return false;
  };

  var scrollToTable = function ($table) {
    window.setTimeout(function () {
      $('html, body').animate({ scrollTop: $table.offset().top }, 200);
    }, 100);
  };

  // Filter the provided table to only display rows for the provided class
  // abbreviation
  var filterTable = function ($table, classAbbrev) {
    $table.find('tr').each(function (i, tr) {
      var $tr = $(tr);
      var rowIsShown = isRowShown($tr, classAbbrev);
      $tr.toggle(rowIsShown);
    });
    scrollToTable($table);

    addFilterLink($table, false);
    $('.itemsUnfilterLink').click(function () {
      unfilterTable($table);
    });

    alert('Filtered to only show rows containing ' + classAbbrev + ' gear.');
  };

  // Restore a table to its previous, un-filtered state
  var unfilterTable = function ($table) {
    $table.find('tr').show();
    addFilterLink($table, true);
    scrollToTable($table);
  };

  // When a user clicks on a table class filter link, handle it by asking them
  // which class (and then filtering the table for that class)
  var handleItemFilterLinkClick = function (e) {
    var promptMessage =
      'Please enter the three-letter abbreviation for the ' +
      'class you want to filter by (eg. "brd" for "Bard").';

    var $table = $(e.target).closest('table');
    var classAbbrev = prompt(promptMessage).toUpperCase();
    if (!classAbbrev || classAbbrev.length !== 3 || classAbbrev === 'ALL') {
      alert(
        'A 3-digit abbreviation wasn\'t entered (or "All" was entered); ' +
          'filtering disabled'
      );
      unfilterTable($table);
      return false;
    }
    filterTable($table, classAbbrev);
  };

  // Adds either a filter or unfilter link (as determined by showFilter) to the
  // provided table
  var addFilterLink = function ($table, showFilter) {
    var $statsHeaderCells = $table.find('th:contains("Stats")');
    var filterType = showFilter ? 'Filter' : 'Unfilter';
    $statsHeaderCells.html(
      'Stats <span style="float:right">' +
        '<a style="text-decoration: underline" class="items' +
        filterType +
        'Link" href="#">' +
        filterType +
        (showFilter ? ' by Class' : '') +
        '</a></span>'
    );

    var $link = $statsHeaderCells.find('.items' + filterType + 'Link');
    if (showFilter) $link.click(handleItemFilterLinkClick);
    else
      $link.click(function () {
        unfilterTable($table);
      });
  };

  // Add class filtering links to all item tables
  var addItemFilteringLinks = function () {
    var $tables = $('table th:contains("Item Name")').closest('table');
    $tables.each(function (i, table) {
      var $table = $(table);
      // Make sure it has both "Item Name" and "Stats" columns
      if (!$table.has('th:contains("Stats")')) return;

      addFilterLink($table, true);
    });
  };
  addItemFilteringLinks();

  //*****************************************
  // End class-based filtering to item tables
  //*****************************************

  // Add No-Drop-Based filtering to the class equipment pages
  if (window.location.pathname.includes('Special:ClassSlotEquip')) {
    // Add checkboxes UI to the page
    $('table').before(
      '<label><input id="showNoDrop" type="checkbox" checked/> Show No Drop</label> ' +
        '<label><input id="showDroppable" type="checkbox" checked/> Show Droppable</label>' /*+
              '| <label><input id="showEffect" type="checkbox" checked/> Show With Effects</label>' +
              '<label><input id="showNoEffect" type="checkbox" checked/> Show Without Effects</label>'*/
    );

    // Handle when drop/no drop box is checked
    $('#showNoDrop, #showDroppable').change(function () {
      var showNoDrop = $('#showNoDrop').is(':checked');
      var showDroppable = $('#showDroppable').is(':checked');
      // NOTE: Some tables are weird and don't keep their TRs in THEAD
      //       .... but those rows do have an old bgcolor="#cccccc" attribute
      //       that we can use to identify (and not hide) them
      $('tbody tr[bgcolor!=#cccccc]').each(function (i, el) {
        var text = $(el).find('td:eq(0) .itemdata').text();
        var isNoDrop = text.includes('NO DROP');
        $(el).toggle((showNoDrop && isNoDrop) || (showDroppable && !isNoDrop));
      });
    });
    /*
Problem: the event handler needs to be merged so unchecking one box (eg. effect) doesn't undo the other (eg. no drop)

    // Handle when effect/no effect box is checked
    $('#showNoEfefct, #showEffect').change(function() {
      var showNoEffect = $('#showNoEffect').is(':checked');
      var showEffect = $('#showEffect').is(':checked');
      // NOTE: Some tables are weird and don't keep their TRs in THEAD
      //       .... but those rows do have an old bgcolor="#cccccc" attribute
      //       that we can use to identify (and not hide) them
      $('tbody tr[bgcolor!=#cccccc]').each(function(i, el) {
        var text = $(el).find('td:eq(0) .itemdata').text();
        var hasEffect = text.includes('NO DROP');
        $(el).toggle((showEffect && hasEffect) || (showNoEffect && !hasEffect));
      });
    });
*/
  }

  // Generic Table Filtering (for Template:TableFilterCheckbox)
  $('.table-filter-checkbox-container').each(function (i, el) {
    var $container = $(el);
    var matchSelector = $container.data('match');
    var text = $container.text();
    $container.html(
      '<label><input class="table-filter-checkbox" type="checkbox" ' +
        'value="' +
        matchSelector +
        '" checked />' +
        text +
        '</label>'
    );
  });

  var shouldRowBeShown = function (tr) {
    var $tr = $(tr);
    // Check all the checkboxes to see if any given row should appear
    // TODO: Instead of checking *every* checkbox, check ones in the same .filter-group
    return $('.table-filter-checkbox')
      .toArray()
      .reduce(function (isShown, checkbox) {
        var matchSelector = $(checkbox).val();
        var matches = $tr.is(matchSelector) || !!$tr.has(matchSelector).length;
        var isChecked = $(checkbox).attr('checked');
        return isShown && !!(isChecked || (!isChecked && !matches));
      }, true);
  };
  $('body').on('change', '.table-filter-checkbox', function (e) {
    $('table').show();
    $('table:not(.toc) tbody tr:not([bgcolor="#cccccc"])').each(function (
      i,
      tr
    ) {
      $(tr).toggle(shouldRowBeShown(tr));
    });
    // If all of a table's non-header rows are hidden, hide it
    $('table')
      .filter(function (i, table) {
        return !$(table).has('tr:visible:not([bgcolor="#cccccc"])').length;
      })
      .hide();
  });

  // Convert youtube template divs into actual iframes
  $('.youtube-placeholder').each(function (i, placeholder) {
    var $placeholder = $(placeholder);
    var data = $placeholder.data();
    var url = data.url;
    // Youtube gets mad if you try to embed watch URLs
    // @see https://stackoverflow.com/questions/25661182/embed-youtube-video-refused-to-display-in-a-frame-because-it-set-x-frame-opti
    //if (url.includes('/watch?') url = url.replace('/watch?', '/embed?');

    // TODO: get height/width from template?
    $placeholder.replaceWith(
      '<iframe ' +
        'width="' +
        (data.width || '') +
        '" ' +
        'height="' +
        (data.height || '') +
        '" ' +
        'src="' +
        url +
        '" ' +
        'frameborder="0" ' +
        'allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" ' +
        'allowfullscreen' +
        '></iframe>'
    );
  });


  importScript('MediaWiki:CheckboxLists.js');
  importScript('MediaWiki:LocMaps.js');

  // Fix searches with url-encoded text
  if (window.location.search.includes('title=Special:Search')) {
    var searchText = $('#searchText').val();
    var decoded = decodeURIComponent(searchText)
    if (decoded !== searchText) {
      // If we have URL-encoded text, which won't give results,
      // replace it with the decoded text and retry
      $('#searchText').val(decoded).closest('form').submit();
    }
  }

  var fullTitle = $('title').text();
  var title = fullTitle.substr(
    0,
    fullTitle.length - ' - Project 1999 Wiki'.length
  );
  switch (title) {
 case 'Admin:Spam Removal':
      importScript('MediaWiki:SpamRemoval.js');
       break;
    case 'Damage Calculator':
      importScript('MediaWiki:DamageCalculator.js');
      break;
    case 'Magelo Blue':
    case 'Magelo Green':
    case 'Magelo Red':
      $('.createboxInput').on('focus', function () {
        this.value = '';
      });
      break;
    // Give the Per-Level Hunting Guide Page its own JS
    case 'Per-Level Hunting Guide':
      importScript('MediaWiki:HuntingGuide.js');
      break;
    // The item category search also needs special JS
    case 'Item Category Search':
      importScript('MediaWiki:ItemCategorySearch.js');
      break;
    // So does the Solo Artist Challenge
    case 'Solo Artist Challenge':
      importScript('MediaWiki:SoloArtistChallenge.js');
      break;
    case 'Treasure Hunting Guide':
      importScript('MediaWiki:TreasureHuntingGuide.js');
      break;
    case 'Buff Lines':
      importScript('MediaWiki:BuffLines.js');
      break;
    case 'FashionQuest Finder':
      importScript('MediaWiki:FashionQuest.js');
      break;
    case 'Magelo Import':
    case 'Item ID Generator':
      importScript('MediaWiki:MageloImport.js');
      break;
    case 'Qeynos':
      var now = new Date();
      if (now.getHours() < 9 || now.getHours() > 21) {
        var $art = $('[src="/images/thumb/Qeynos_Day_Art.jpg/300px-Qeynos_Day_Art.jpg"]');
        $art.attr('src', '/images/thumb/Qeynos_Night_Art.jpg/300px-Qeynos_Night_Art.jpg');
        $art.parent().attr('href', 'https://wiki.project1999.com/File:Qeynos_Night_Art.jpg');
      }
      break;
    case 'Mobs By Level':
      // Basic DOM manipulation (since the wiki won't let us add <input> tags in the wiki text
      $('#placeholder').replaceWith(
        '<form id="form">' +
          'Class: <input id="class" style="width: 8em" value="Warrior" /> ' +
          'Level: <input id="level" style="width: 4em" /> ' +
          '<input type="submit"/>' +
          '</form>'
      );
      $('#form').submit(function () {
        var clazz = $('#class').val();
        var level = $('#level').val();
        window.location =
          'https://wiki.project1999.com/index.php?title=Special:Search&limit=500&offset=0&redirs=0&profile=default&search=%22Level%5C%3A%5C+' +
          level +
          '%22+-%22Shopkeeper%22+-%22Merchant%22+%22Class%5C%3A%5C+' +
          clazz +
          '%22+-"startMageloProfile"';
        return false;
      });
      break;
    case 'Patch Notes':
      // Let users control patch note width and how
      // the table of contents is shown

      var setNotesColumns = function (cols) {
        $('#notesWrapper').css('width', cols ? cols + 'em' : '');
      };
      var toggleNotRelevant = function (isChecked) {
        $('#notesWrapper').find(':not(:visible)').show();
        if (isChecked) return;

        $('#notesWrapper')
          .children()
          .filter(function (i, el) {
            var isRelevant = $(el).is('.relevant');
            $(el).toggle(isRelevant);
            // Show the patch header also
            if (isRelevant) $(el).prevUntil('h3').last().prev().show();
          });
      };
      var toggleTableOfContents = function (isChecked) {
        $('#toc').toggle(isChecked);
      };
      var togglePatchHeaders = function (isChecked) {
        $('.toclevel-4').toggle(isChecked);
      };

      var notesWidth = getStateByPage('notes-width') || '';
      var showTableOfContents =
        getStateByPage('show-table-of-contents') !== false;
      var showPatchHeaders = getStateByPage('show-patch-headers') !== false;
      var showNotRelevant = getStateByPage('show-not-relevant') !== false;

      setNotesColumns(notesWidth);
      toggleTableOfContents(showTableOfContents);
      togglePatchHeaders(showPatchHeaders);
      toggleNotRelevant(showNotRelevant);

      $('#patchNoteOptions').html(
        '<div><label><input ' +
          (showTableOfContents ? 'checked ' : '') +
          'id="show-table-of-contents" type="checkbox" /> Show Table of Contents?<label><br/>' +
          '<label><input ' +
          (showPatchHeaders ? 'checked ' : '') +
          'id="show-patch-headers"  type="checkbox"/> Show patch headers in Table of Contents?<label><br/>' +
          '<label><input ' +
          (showNotRelevant ? 'checked ' : '') +
          'id="show-not-relevant"  type="checkbox"/> Show content not marked as relevant to P99?<label></div>' +
          '<div><label>Limit notes to a width of <input id="notes-width" placeholder="40" style="width: 4em;" value="' +
          notesWidth +
          '" /> columns (for readability)</label></div>'
      );
      $('#notes-width').change(function (e) {
        var cols = parseFloat($(e.target).val());
        setNotesColumns(cols);
        setStateByPage('notes-width', cols);
      });
      $('#show-table-of-contents').change(function (e) {
        var isChecked = $(e.target).is(':checked');
        toggleTableOfContents(isChecked);
        setStateByPage('show-table-of-contents', isChecked);
      });
      $('#show-patch-headers').change(function (e) {
        var isChecked = $(e.target).is(':checked');
        togglePatchHeaders(isChecked);
        setStateByPage('show-patch-headers', isChecked);
      });
      $('#show-not-relevant').change(function (e) {
        var isChecked = $(e.target).is(':checked');
        toggleNotRelevant(isChecked);
        setStateByPage('show-not-relevant', isChecked);
      });

      break;
  }

  // Warn users who accidentally try to edit a templated section
  if (
    (window.location + '').includes('title=Template:Namedmobpage&action=edit')
  ) {
    alert(
      "Warning: You are attempting you edit the template for all named mobs.  You probably didn't mean to do that: you probably clicked on an edit link somewhere in the page and wound up here.  To avoid this simply go back and use the edit *tab* at the top of the page instead."
    );
  }

  // Warn uses who try to edit a mostly-transcluded section (and offer to take them to the transcluded page)
  var text = $('#wpTextbox1').val();
  var match = text && text.match(/\{\{\#lsth\:(.*?)\|\[\[(.*?)\]\](.*)\}\}/);
  if (match) {
    var pageName = match[1];
    var section = match[2];
    var extra = match[3] || '';
    var isEdit = (window.location + '').includes('action=edit');
    var isUnderTenLines = $('#wpTextbox1').val().split('\n').length < 10;
    var url = '/' + pageName + '#' + (section + extra).replace(/ /g, '_');
    var message =
      'This page uses "transclusion" to show part of another page, specifically the code:\n\n    {{#lsth:' +
      pageName +
      '|[[' +
      section +
      ']]' +
      extra +
      '}}\n\nIf you want to edit the transcluded section, click "Ok": you will be taken to that page.' +
      '  If you want to stay on this page, click "Cancel".';
    if (isEdit && isUnderTenLines && confirm(message)) location = url;
  }

  // *** AUCTION TRACKER RELATED ***

  var selectedServer = getGlobalState('selectedServer') || 'Blue';
  var buildTabHtml = function (serverNames) {
    var tabDivs = serverNames.map(function (name, i) {
      return (
        '<div id="' +
        name +
        'Tab" class="tab' +
        (name === selectedServer ? ' selected' : '') +
        '">' +
        name +
        '</div>'
      );
    });
    return (
      '<div class="tabs">' +
      tabDivs.join('') +
      '<div class="clear"></div></div>'
    );
  };

  var selectTab = function (name) {
    // show the server's box (and hide the others)
    $trackers.hide();
    var $tracker = $('#auc_' + name).show();

    // If the selected tab doesn't exist on this item, show the first tab instead
    if (!$tracker.length) $tracker = $('.auctrackerbox:first').show();

    // Select the tab for that server (and unselect the others)
    $('.auctrackerbox .tab').removeClass('selected');
    var $tab = $('.auctrackerbox .tab:contains("' + name + '")');
    if (!$tab.length) $tab = $('.auctrackerbox .tab:first');
    $tab.addClass('selected');
    setGlobalState('selectedServer', name);
  };

  var $trackers = $('.auctrackerbox');
  // if we're on an item page with an auction tracker
  if ($trackers.length) {
    // Convert IDs of "auc_Blue" into an array of ["Blue", "Green", ...]
    var servers = $trackers.toArray().map(function (tracker) {
      return tracker.id.substr(4);
    });
    $trackers.prepend(buildTabHtml(servers));
    selectTab(selectedServer);
    $trackers.on('click', '.tab', function (e) {
      selectTab($(e.target).text());
    });

    // Add links to search the forum for items to their auction tracker

    // Forum IDs come from forum URLs, eg. red's auction forum is:
    //   https://www.project1999.com/forums/forumdisplay.php?f=59
    // so  we have "red: 59 " below
    var forumIds = { blue: 27, green: 77, red: 59, teal: 78 };

    $('.auctrackerbox span span:contains("Project 1999 Auction Tracker")').each(
      function (i, el) {
        var $el = $(el);
        var serverName = $el
          .closest('.auctrackerbox')
          .attr('id')
          .substr(4)
          .toLowerCase();
        const forumSearchUrl =
          'https://www.project1999.com/forums/search.php?do=process&forumchoice[]=' +
          forumIds[serverName] +
          '&query=%22' +
          $('#firstHeading').text() +
          '%22';
        $el.append(
          '<a ' +
            'style="font-size: 0.5em; float: right" ' +
            'target="_new" ' +
            'href="' +
            forumSearchUrl +
            '"' +
            '>Search Forum</a>'
        );
      }
    );
  }

  // Dynamic zone page-related
  // NOTE: Dynamic zone pages are used on every zone page in the site, to
  //       display tables containing every mob/item in that zone.  They find the
  //       pages for this list by checking the zone's wiki category.
  //
  //       However, at times an errant character in any one of the pages in the
  //       category (eg. an editor pastes a Unicode character in) can break the
  //       dynamic zone (PHP) code.
  //
  //       The code below first provides a link to explain the issue to regular
  //       users, and then it adds a "secret" function for admins and power
  //       users to more easily find recently edited pages (that could have
  //       caused the problem).

  // Add a help link to dynamic zone lists, to explain how to fix them if they break
  // NOTE: This could be better.  Potential improvements:
  // A) we could only show the message when the table is actually (ie. every column except the first, of every row, is empty)
  // B) instead of just showing an alert, we could show a confirm, and if they click ok we could send them to the zone's category page
  // C) we could add logic to zone category pages to make them have a "History Mode" button, which converts all links to go straight to the history and open in a new tab
  //    (this way people could actually fix the table by clicking every link, then closing the ones without recent edits ... we *could* even click every link for them ...)
  //    EDIT: Actually, we have C) below now, but currently it's "secret", for
  //          admins or power users, because if used en masse it could DOS the
  //          wiki
  try {

    const $mobsTable = $("#What\\.27s_in_this_zone")
      .parent()
      .next("p")
      .next("table");



    $mobsTable.after(
      '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
    );

    const $itemsTable = $mobsTable.next("div").next("p").next();
    $itemsTable.after(
      '<div style="font-size:0.85em; text-align:right; max-width: '+ $mobsTable.width()+'px"><a class="broken-dynamic-table-help" href="#">Is this table not working?</a></div>'
    );

    $(".broken-dynamic-table-help").click(function(e){
      e.preventDefault();
      alert(
        "This table updates itself nightly with data from other mob/item pages.  If one of the pages contains even a single invalid character (eg. a smiley face or other Unicode character), it can break that update.\n\nUnfortunately, at this time the only fix is to find the offending page and correct it (and then wait up to 24 hours to see if it worked).  However, wiki administrators (see /administrators) have tools that can help, so contacting one of them is recommended."
      );
    });
  } catch (err) {
    1 + 1; /* Wed don't care; it probably was a page with no dynamic zone list */
  }


  /**
 * Given HTML from a wiki history page, this function determines whether
 * that page contains edits from the last ${months} months.
 * @param html a string of HTML from a wiki entry's history page
 * @param months a number of months to check
 * @returns true if the page has recent edits
 */
function wasEditedRecently(html, months) {
  var nMonthsAgo = new Date();
  nMonthsAgo.setMonth(new Date().getMonth() - months);

  var dateString = $(html)
    .find("#pagehistory li:first() .mw-changeslist-date")
    .text();
  var splitDate = dateString.split(" ");
  var day = splitDate.at(-3);
  var month = splitDate.at(-2);
  var year = splitDate.at(-1);

  var newDateString = month + " " + day + "," + year;
  var lastEditDate = new Date(Date.parse(newDateString));

  return lastEditDate > nMonthsAgo;
}
/**
 * With modern Javascript (async/await) iterating through a queue of fetches
 * with a delay is trivially easy ... but the wiki doesn't like modern
 * Javascript, so we need a recursive function, a custom promise, and a timeout,
 * just to accomplish that basic task.
 *
 * It's worth noting that the delay from the timeout is very important: without
 * it this function would flood the wiki with requests (potentially 100+ , all
 * at once).  Each fetch also needs to run sequentially (not in parallel, which
 * would be faster) for that same reason.
 */
function fetchPagesRecursivelyWithDelay(links, months) {
  var url = links.pop();
  return new Promise(function (resolve) {
    fetch(url).then(function (response) {
      return response.text().then(function (text) {
        // Let user know how many more remain
        $("#pagesLeftCount").text(links.length);

        if (wasEditedRecently(text, months)) {
          var title = url
            .substring(
              "https://wiki.project1999.com/index.php?action=history&title="
                .length
            )
            .replaceAll("_", " ");
          $("#recentEdits").append(
            '<li><a href="' + url + '" target="_blank">' + title + "</a></li>"
          );
        }

        if (links.length) {
          // This handles the recursive case:
          // resolve this recursion's promise ... after the next recursion
          window.setTimeout(function () {
            fetchPagesRecursivelyWithDelay(links, months).then(function () {
              resolve();
            });
          }, 1000);
        } else {
          // This handles the final case
          resolve();
        }
      });
    });
  });
}

function addListToPageBottom(initialLinkCount) {
  $("#mw-normal-catlinks")
    .parent()
    .before(
      "<h1>Recent (<6 months) Edits</h1>" +
        '<ul id="recentEdits" style="margin-bottom:2em"></ul>' +
        '<div id="waitForIt">' +
        "Please be patient!  Because we don't want to overload the wiki " +
        "server, history is checked <em>slowly</em>.\n" +
        'There are currently <span id="pagesLeftCount">' +
        initialLinkCount +
        "</span> pages remaining to check." +
        "</div>"
    );
}

/**
 * Adds a new "debugging" section to the bottom of the current wiki page,
 * with an initially empty list.  The function then fetches the history
 * of every page in the current pages dynamic zone lists, one at a time.
 *
 * Each page is checked to see if they have been edited within the provided
 * number of months, and if so they are added to the list.
 *
 * Six is the recommended number of months to start, but the parameter can
 * be changed in case a page has sat broken for over half a year (and a
 * larger number will be needed to find the bad edit), or in case a page was
 * known to have broken more recently (in which case a lower number can shorten
 * the search).
 *
 * @param months
 * @usage From the console on a page with a broken dynamic zone table:
 *        checkForDynamicZonePagesEditedRecently(6);
 *
 */
window.checkForDynamicZonePagesEditedRecently = function (months) {
  var links = $('th:contains("NPC Name"), th:contains("Item Name")')
    .closest("table")
    .children("tbody")
    .children("tr")
    .map(function (i, tr) {
      var url = $(tr).children("td").eq(0).find("a").attr("href");
      return (
        "https://wiki.project1999.com/index.php?action=history&title=" +
        url.split("/").at(-1)
      );
    })
    .toArray();

  // Reverse the links, because the recursion reverses their order.  This isn't
  // really *necessary*, but it looks odd to see the Z's appear before the A's
  links.reverse();

  // Add the header and list, with wait message
  addListToPageBottom(links.length);

  fetchPagesRecursivelyWithDelay(links, months).then(function () {
    // Remove the wait message
    $("#waitForIt").remove();
  });
}


// End onReady code
});