User:Gary/comments in local time.js

This is an old revision of this page, as edited by Gary (talk | contribs) at 02:28, 13 April 2019 (Major cleanup of the code. It otherwise works the same. If something is majorly broken, then please ask an admin to undo this. For minor bugs, just post to the script's talk page.). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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.
/* eslint-disable complexity, max-statements */
/**
 * COMMENTS IN LOCAL TIME
 *
 * Description:
 * Changes [[Coordinated Universal Time|UTC]]-based times and dates,
 * such as those used in signatures, to be relative to local time.
 *
 * Documentation:
 * [[Wikipedia:Comments in Local Time]]
 */
$(() => {
  /**
   * Given a number, add a leading zero if necessary, so that the final number
   * has two characters.
   *
   * @param {number} number Number
   * @returns {string} The number with a leading zero, if necessary.
   */
  function addLeadingZero(number) {
    const numberArg = number;

    if (numberArg < 10) {
      return `0${numberArg}`;
    }

    return numberArg;
  }

  function convertMonthToNumber(month) {
    return new Date(`${month} 1, 2001`).getMonth();
  }

  /**
   * Determine whether to use the singular or plural word, and use that.
   *
   * @param {string} term Original term
   * @param {number} count Count of items
   * @param {string} plural Pluralized term
   * @returns {string} The word to use
   */
  function pluralize(term, count, plural = null) {
    let pluralArg = plural;

    // No unique pluralized word is found, so just use a general one.
    if (!pluralArg) {
      pluralArg = `${term}s`;
    }

    // There's only one item, so just use the singular word.
    if (count === 1) {
      return term;
    }

    // There are multiple items, so use the plural word.
    return pluralArg;
  }

  class CommentsInLocalTime {
    constructor() {
      this.language = '';
      this.LocalComments = {};

      /**
       * Settings
       */
      this.settings();

      this.language = this.setDefaultSetting(
        'language',
        this.LocalComments.language
      );

      // These values are also reflected in the documentation:
      // https://en.wikipedia.org/wiki/Wikipedia:Comments_in_Local_Time#Default_settings
      this.setDefaultSetting({
        dateDifference: true,
        dateFormat: 'dmy',
        dayOfWeek: true,
        dropDays: 0,
        dropMonths: 0,
        timeFirst: true,
        twentyFourHours: false,
      });
    }

    adjustTime(originalTimestamp, search) {
      let time = originalTimestamp.match(search);
      const [, oldHour, oldMinute, oldDay, oldMonth, oldYear] = time;

      // Today
      const today = new Date();

      // Yesterday
      const yesterday = new Date();

      yesterday.setDate(yesterday.getDate() - 1);

      // Tomorrow
      const tomorrow = new Date();

      tomorrow.setDate(tomorrow.getDate() + 1);

      // Set the date entered.
      time = new Date();
      time.setUTCFullYear(oldYear, convertMonthToNumber(oldMonth), oldDay);
      time.setUTCHours(oldHour);
      time.setUTCMinutes(oldMinute);

      // A string matching the date pattern was found, but it cannot be
      // converted to a Date object. Return it with no changes made.
      if (Number.isNaN(time)) {
        return [originalTimestamp, ''];
      }

      // Determine the time offset.
      const utcValue = (-1 * time.getTimezoneOffset()) / 60;
      const utcOffset =
        utcValue >= 0 ? `+${utcValue}` : `−${Math.abs(utcValue.toFixed(1))}`;

      // Set the date bits to output.
      const year = time.getFullYear();
      const month = addLeadingZero(time.getMonth() + 1);
      const day = time.getDate();
      let hour = parseInt(time.getHours(), 10);
      const minute = addLeadingZero(time.getMinutes());

      // Output am or pm depending on the date.
      let ampm = '';

      if (this.LocalComments.twentyFourHours) {
        hour = addLeadingZero(hour);
      } else {
        ampm = hour <= 11 ? ' am' : ' pm';

        if (hour > 12) {
          hour -= 12;
        } else if (hour === 0) {
          hour = 12;
        }
      }

      // return 'today' or 'yesterday' if that is the case
      let date;

      if (
        year === today.getFullYear() &&
        month === addLeadingZero(today.getMonth() + 1) &&
        day === today.getDate()
      ) {
        date = this.language.Today;
      } else if (
        year === yesterday.getFullYear() &&
        month === addLeadingZero(yesterday.getMonth() + 1) &&
        day === yesterday.getDate()
      ) {
        date = this.language.Yesterday;
      } else if (
        year === tomorrow.getFullYear() &&
        month === addLeadingZero(tomorrow.getMonth() + 1) &&
        day === tomorrow.getDate()
      ) {
        date = this.language.Tomorrow;
      } else {
        // calculate day of week
        const dayNames = [
          this.language.Sunday,
          this.language.Monday,
          this.language.Tuesday,
          this.language.Wednesday,
          this.language.Thursday,
          this.language.Friday,
          this.language.Saturday,
        ];
        const dayOfTheWeek = dayNames[time.getDay()];
        let descriptiveDifference = '';
        let last = '';

        // Create a relative descriptive difference
        if (this.LocalComments.dateDifference) {
          ({ descriptiveDifference, last } = this.createRelativeDate(
            today,
            time
          ));
        }

        const monthName = this.convertNumberToMonth(time.getMonth());

        // format the date according to user preferences
        let formattedDate = '';

        switch (this.LocalComments.dateFormat.toLowerCase()) {
          case 'dmy':
            formattedDate = `${day} ${monthName} ${year}`;

            break;
          case 'mdy':
            formattedDate = `${monthName} ${day}, ${year}`;

            break;
          default:
            formattedDate = `${year}-${month}-${addLeadingZero(day)}`;
        }

        let formattedDayOfTheWeek = '';

        if (this.LocalComments.dayOfWeek) {
          formattedDayOfTheWeek = `, ${last}${dayOfTheWeek}`;
        }

        date = formattedDate + formattedDayOfTheWeek + descriptiveDifference;
      }

      const finalTime = `${hour}:${minute}${ampm}`;
      let returnDate;

      if (this.LocalComments.timeFirst) {
        returnDate = `${finalTime}, ${date} (UTC${utcOffset})`;
      } else {
        returnDate = `${date}, ${finalTime} (UTC${utcOffset})`;
      }

      return [returnDate, time];
    }

    convertNumberToMonth(number) {
      return [
        this.language.January,
        this.language.February,
        this.language.March,
        this.language.April,
        this.language.May,
        this.language.June,
        this.language.July,
        this.language.August,
        this.language.September,
        this.language.October,
        this.language.November,
        this.language.December,
      ][number];
    }

    createRelativeDate(today, time) {
      // Calculate time difference from today.
      const millisecondsAgo = today.getTime() - time.getTime();

      let daysAgo = Math.abs(Math.round(millisecondsAgo / 1000 / 60 / 60 / 24));
      let differenceWord = '';
      let last = '';

      if (millisecondsAgo >= 0) {
        differenceWord = this.language.ago;

        if (daysAgo <= 7) {
          last = `${this.language.last} `;
        }
      } else {
        differenceWord = this.language['from now'];

        if (daysAgo <= 7) {
          last = `${this.language.this} `;
        }
      }

      // This method of computing the years & months is not exact. However, it's
      // better than the previous method that used 1 January + delta days. That
      // was usually quite off because it mapped the second delta month to
      // February, which has only 28 days. This method is usually not more than
      // one day off.

      /**
       * The number of months ago that we will display. It's not necessarily the
       * total months ago.
       *
       * @type {number}
       */
      let monthsAgo = Math.floor((daysAgo / 365) * 12);

      /**
       * The total amount of time ago, in months.
       *
       * @type {number}
       */
      const totalMonthsAgo = monthsAgo;

      /**
       * The number of years ago that we will display. It's not necessarily the
       * total years ago.
       */
      let yearsAgo = Math.floor(totalMonthsAgo / 12);

      if (totalMonthsAgo < this.LocalComments.dropMonths) {
        yearsAgo = 0;
      } else if (this.LocalComments.dropMonths > 0) {
        monthsAgo = 0;
      } else {
        monthsAgo -= yearsAgo * 12;
      }

      if (daysAgo < this.LocalComments.dropDays) {
        monthsAgo = 0;
        yearsAgo = 0;
      } else if (this.LocalComments.dropDays > 0 && totalMonthsAgo >= 1) {
        daysAgo = 0;
      } else {
        daysAgo -= Math.floor((totalMonthsAgo * 365) / 12);
      }

      const descriptiveParts = [];

      if (yearsAgo > 0) {
        const fmtYears = `${yearsAgo} ${pluralize(
          this.language.year,
          yearsAgo,
          this.language.years
        )}`;

        descriptiveParts.push(fmtYears);
      }

      if (monthsAgo > 0) {
        const fmtMonths = `${monthsAgo} ${pluralize(
          this.language.month,
          monthsAgo,
          this.language.months
        )}`;

        descriptiveParts.push(fmtMonths);
      }

      if (daysAgo > 0) {
        const fmtDays = `${daysAgo} ${pluralize(
          this.language.day,
          daysAgo,
          this.language.days
        )}`;

        descriptiveParts.push(fmtDays);
      }

      return {
        descriptiveDifference: ` (${descriptiveParts.join(
          ', '
        )} ${differenceWord})`,
        last,
      };
    }

    replaceText(node, search) {
      if (!node) {
        return;
      }

      // Check if this is a text node.
      if (node.nodeType === 3) {
        let parent = node.parentNode;

        const parentNodeName = parent.nodeName;

        if (['CODE', 'PRE'].includes(parentNodeName)) {
          return;
        }

        const value = node.nodeValue;
        const matches = value.match(search);

        // Stick with manipulating the DOM directly rather than using jQuery.
        // I've got more than a 100% speed improvement afterward.
        if (matches) {
          // Only act on the first timestamp we found in this node. This is
          // really a temporary fix for the situation in which there are two or
          // more timestamps in the same node.
          const [match] = matches;
          const position = value.search(search);
          const stringLength = match.toString().length;
          const beforeMatch = value.substring(0, position);
          const afterMatch = value.substring(position + stringLength);
          const timeArray = this.adjustTime(match.toString(), search);
          const timestamp = timeArray[1] ? timeArray[1].getTime() : '';

          // Is the "timestamp" attribute used for microformats?
          const span = document.createElement('span');

          span.className = 'localcomments';
          span.style.fontSize = '95%';
          span.style.whiteSpace = 'nowrap';
          span.setAttribute('timestamp', timestamp);
          span.title = match;
          span.append(document.createTextNode(timeArray[0]));

          parent = node.parentNode;
          parent.replaceChild(span, node);

          const before = document.createElement('span');

          before.className = 'before-localcomments';
          before.append(document.createTextNode(beforeMatch));

          const after = document.createElement('span');

          after.className = 'after-localcomments';
          after.append(document.createTextNode(afterMatch));

          parent.insertBefore(before, span);
          parent.insertBefore(after, span.nextSibling);
        }
      } else {
        const children = [];
        let child;

        [child] = node.childNodes;

        while (child) {
          children.push(child);
          child = child.nextSibling;
        }

        // Loop through children and run this func on it again, recursively.
        children.forEach((child2) => {
          this.replaceText(child2, search);
        });
      }
    }

    run() {
      if (
        mw.config.get('wgCanonicalNamespace') === '' ||
        mw.config.get('wgCanonicalNamespace') === 'MediaWiki' ||
        mw.config.get('wgCanonicalNamespace') === 'Special'
      ) {
        return;
      }

      const disabledUrls = ['action=history'];

      // Check for disabled URLs.
      const isDisabledUrl = disabledUrls.some((disabledUrl) =>
        document.___location.href.includes(disabledUrl)
      );

      if (isDisabledUrl) {
        return;
      }

      const wikiPreview = ['action=edit', 'action=submit'];

      // Check what ID we should use, to get the page's text.
      const hasUniqueUrl = wikiPreview.some((text) =>
        document.___location.href.includes(text)
      );

      const elementId = hasUniqueUrl ? 'wikiPreview' : 'bodyContent';
      const contentText = document.querySelector(`#${elementId}`);

      this.replaceText(
        contentText,
        /(\d{1,2}):(\d{2}), (\d{1,2}) ([A-Z][a-z]+) (\d{4}) \(UTC\)/
      );
    }

    setDefaultSetting(...args) {
      // There are no arguments.
      if (args.length === 0) {
        return false;
      }

      // The first arg is an object, so just set that data directly onto the
      // settings object. like {setting 1: true, setting 2: false}
      if (typeof args[0] === 'object') {
        const [settings] = args;

        // Loop through each setting.
        Object.keys(settings).forEach((name) => {
          const value = settings[name];

          if (typeof this.LocalComments[name] === 'undefined') {
            this.LocalComments[name] = value;
          }
        });

        return settings;
      }

      // The first arg is a string, so use the first arg as the settings key,
      // and the second arg as the value to set it to.
      const [name, setting] = args;

      if (typeof this.LocalComments[name] === 'undefined') {
        this.LocalComments[name] = setting;
      }

      return this.LocalComments[name];
    }

    /**
     * Set the script's settings.
     *
     * @returns {undefined}
     */
    settings() {
      // The user has set custom settings, so use those.
      if (window.LocalComments) {
        this.LocalComments = window.LocalComments;
      }

      /**
       * Language
       *
       * LOCALIZING THIS SCRIPT
       * To localize this script, change the terms below,
       * to the RIGHT of the colons, to the correct term used in that language.
       *
       * For example, in the French language,
       *
       * 'Today' : 'Today',
       *
       * would be
       *
       * 'Today' : "Aujourd'hui",
       */
      this.LocalComments.language = {
        // Relative terms
        Today: 'Today',
        Yesterday: 'Yesterday',
        Tomorrow: 'Tomorrow',
        last: 'last',
        this: 'this',

        // Days of the week
        Sunday: 'Sunday',
        Monday: 'Monday',
        Tuesday: 'Tuesday',
        Wednesday: 'Wednesday',
        Thursday: 'Thursday',
        Friday: 'Friday',
        Saturday: 'Saturday',

        // Months of the year
        January: 'January',
        February: 'February',
        March: 'March',
        April: 'April',
        May: 'May',
        June: 'June',
        July: 'July',
        August: 'August',
        September: 'September',
        October: 'October',
        November: 'November',
        December: 'December',

        // Difference words
        ago: 'ago',
        'from now': 'from now',

        // Date phrases
        year: 'year',
        years: 'years',
        month: 'month',
        months: 'months',
        day: 'day',
        days: 'days',
      };
    }
  }

  const commentsInLocalTime = new CommentsInLocalTime();

  commentsInLocalTime.run();
});