Jump to content

MediaWiki:CommentsInLocalTime.js

From Wikipedia, the free encyclopedia
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.
/**
 * 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();
  }

  function getDates(time) {
    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.
    const newTime = new Date();

    newTime.setUTCFullYear(oldYear, convertMonthToNumber(oldMonth), oldDay);
    newTime.setUTCHours(oldHour);
    newTime.setUTCMinutes(oldMinute);

    return { time: newTime, today, tomorrow, yesterday };
  }

  /**
   * 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) {
      const { time, today, tomorrow, yesterday } = getDates(
        originalTimestamp.match(search)
      );

      // 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, ''];
      }

      const date = this.determineDateText({
        time,
        today,
        tomorrow,
        yesterday,
      });

      const { ampm, hour } = this.getHour(time);
      const minute = addLeadingZero(time.getMinutes());
      const finalTime = `${hour}:${minute}${ampm}`;
      let returnDate;

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

      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];
    }

    createDateText({ day, month, time, today, year }) {
      // 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}`;
      }

      return formattedDate + formattedDayOfTheWeek + descriptiveDifference;
    }

    /**
     * Create relative date data.
     *
     * @param {Date} today Today
     * @param {Date} time The timestamp from a comment
     * @returns {Object.<string, *>} Relative date data
     */
    createRelativeDate(today, time) {
      /**
       * The time difference from today, in milliseconds.
       *
       * @type {number}
       */
      const millisecondsAgo = today.getTime() - time.getTime();

      /**
       * The number of days ago, that we will display. It's not necessarily the
       * total days ago.
       *
       * @type {number}
       */
      let daysAgo = Math.abs(Math.round(millisecondsAgo / 1000 / 60 / 60 / 24));
      const { differenceWord, last } = this.relativeText({
        daysAgo,
        millisecondsAgo,
      });

      // This method of computing the years and 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, except perhaps over very distant dates.

      /**
       * 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.
       *
       * @type {number}
       */
      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 = [];

      // There is years text to add.
      if (yearsAgo > 0) {
        descriptiveParts.push(
          `${yearsAgo} ${pluralize(
            this.language.year,
            yearsAgo,
            this.language.years
          )}`
        );
      }

      // There is months text to add.
      if (monthsAgo > 0) {
        descriptiveParts.push(
          `${monthsAgo} ${pluralize(
            this.language.month,
            monthsAgo,
            this.language.months
          )}`
        );
      }

      // There is days text to add.
      if (daysAgo > 0) {
        descriptiveParts.push(
          `${daysAgo} ${pluralize(
            this.language.day,
            daysAgo,
            this.language.days
          )}`
        );
      }

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

    determineDateText({ time, today, tomorrow, yesterday }) {
      // Set the date bits to output.
      const year = time.getFullYear();
      const month = addLeadingZero(time.getMonth() + 1);
      const day = time.getDate();

      // Return 'today' or 'yesterday' if that is the case
      if (
        year === today.getFullYear() &&
        month === addLeadingZero(today.getMonth() + 1) &&
        day === today.getDate()
      ) {
        return this.language.Today;
      }

      if (
        year === yesterday.getFullYear() &&
        month === addLeadingZero(yesterday.getMonth() + 1) &&
        day === yesterday.getDate()
      ) {
        return this.language.Yesterday;
      }

      if (
        year === tomorrow.getFullYear() &&
        month === addLeadingZero(tomorrow.getMonth() + 1) &&
        day === tomorrow.getDate()
      ) {
        return this.language.Tomorrow;
      }

      return this.createDateText({ day, month, time, today, year });
    }

    getHour(time) {
      let ampm;
      let hour = parseInt(time.getHours(), 10);

      if (this.LocalComments.twentyFourHours) {
        ampm = '';
        hour = addLeadingZero(hour);
      } else {
        // Output am or pm depending on the date.
        ampm = hour <= 11 ? ' am' : ' pm';

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

      return { ampm, hour };
    }

    relativeText({ daysAgo, millisecondsAgo }) {
      let differenceWord = '';
      let last = '';

      // The date is in the past.
      if (millisecondsAgo >= 0) {
        differenceWord = this.language.ago;

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

        // The date is in the future.
      } else {
        differenceWord = this.language['from now'];

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

      return { 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 { returnDate, time } = this.adjustTime(
            match.toString(),
            search
          );
          const timestamp = time ? time.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(returnDate));

          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 (
        ['', 'MediaWiki', 'Special'].includes(
          mw.config.get('wgCanonicalNamespace')
        )
      ) {
        return;
      }

      // Check for disabled URLs.
      const isDisabledUrl = ['action=history'].some((disabledUrl) =>
        document.location.href.includes(disabledUrl)
      );

      if (isDisabledUrl) {
        return;
      }

      this.replaceText(
        document.querySelector('.mw-parser-output'),
        /(\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',
      };
    }
  }

  // Check if we've already ran this script.
  if (window.commentsInLocalTimeWasRun) {
    return;
  }

  window.commentsInLocalTimeWasRun = true;

  const commentsInLocalTime = new CommentsInLocalTime();

  commentsInLocalTime.run();
});