Jump to content

User:SD0001/comments in local time.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by SD0001 (talk | contribs) at 13:18, 31 March 2021 (oops, add attribution comment). 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.
// Forked from [[User:Gary/comments in local time.js]]
/**
 * 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]]
 */
mw.hook('wikipage.content').add(($content) => {
	/**
	 * 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;
			}

			// If this content already contains localcomments class, it was
			// already worked on.
			if ($content[0].querySelector('.localcomments')) {
				return;
			}

			this.replaceText(
				$content[0],
				/(\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',
			};
		}
	}

	new CommentsInLocalTime().run();
});