MediaWiki:CommentsInLocalTime.js

/** * COMMENTS IN LOCAL TIME * * Description: * Changes UTC-based times and dates, * such as those used in signatures, to be relative to local time. * * Documentation: * 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.} 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; });