User:Mxn/CommentsInLocalTime/sandbox.js

This is an old revision of this page, as edited by Mxn (talk | contribs) at 09:31, 8 June 2025 (Undid revision 1294539792 by Mxn (talk)). 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.
/**
 * Comments in local time
 * [[User:Mxn/CommentsInLocalTime]]
 * 
 * Adjust timestamps in comment signatures to use easy-to-understand, relative
 * local time instead of absolute UTC time.
 * 
 * Inspired by [[Wikipedia:Comments in Local Time]].
 * 
 * @author [[User:Mxn]]
 */

/**
 * Default settings for this gadget.
 */
window.LocalComments = $.extend({
	// USER OPTIONS ////////////////////////////////////////////////////////////
	
	/**
	 * When false, this gadget does nothing.
	 */
	enabled: true,
	
	/**
	 * Formats to display inline for each timestamp.
	 * 
	 * If a property is an object, its `type` and `options` may be:
	 * 
	 * `type`     | `options`
	 * -----------|----------
	 * `relative` | `Intl.RelativeTimeFormat` options
	 * `absolute` | `Intl.AbsoluteTimeFormat` options
	 * `iso8601`  | —
	 * 
	 * If a property is a function, it is called to retrieve the formatted
	 * timestamp string. The function must accept one argument, a `Date` object.
	 */
	outputFormats: {
		/**
		 * Relative dates are helpful if the user doesn’t remember today’s date.
		 * The tooltip provides a more specific timestamp to distinguish
		 * comments in rapid succession.
		 * 
		 * See <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options>.
		 */
		relative: {
			numeric: "auto",
		},
		
		/**
		 * Absolute dates are helpful for more distant dates, so that the user
		 * doesn’t have to do math in their head.
		 * 
		 * See <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#date-time_component_options>
		 * and <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts>.
		 */
		absolute: {
			dateStyle: "long",
			timeStyle: "short",
		},
	},
	
	/**
	 * Formats to display in each timestamp’s tooltip, one per line.
	 * 
	 * If an element of this array is an object its `type` and `options` may be:
	 * 
	 * `type`      | `options`
	 * ------------|----------
	 * `relative`  | `Intl.RelativeTimeFormat` options
	 * `absolute`  | `Intl.AbsoluteTimeFormat` options
	 * `mediawiki` | —
	 * `iso8601`   | —
	 * 
	 * See:
	 * <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options>
	 * <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#date-time_component_options>
	 * <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#style_shortcuts>
	 * 
	 * If an element of this array is a function, it is called to retrieve the
	 * formatted timestamp string. The function must accept one argument, a
	 * `Date` object.
	 */
	tooltipComponents: [
		{
			type: "relative",
			options: {
				numeric: "auto",
			},
		},
		{
			type: "absolute",
			options: {
				dateStyle: "full",
				timeStyle: "short",
			},
		},
		{
			type: "iso8601",
		},
	],
	
	/**
	 * When true, this gadget refreshes timestamps periodically.
	 */
	dynamic: true,
}, {
	// SITE OPTIONS ////////////////////////////////////////////////////////////
	
	/**
	 * Numbers of namespaces to completely ignore. See [[Wikipedia:Namespace]].
	 */
	excludeNamespaces: [-1, 0, 8, 100, 108, 118],
	
	/**
	 * Names of tags that often directly contain timestamps.
	 * 
	 * This is merely a performance optimization. This gadget will look at text
	 * nodes in any tag other than the codeTags, but adding a tag here ensures
	 * that it gets processed the most efficient way possible.
	 */
	proseTags: ["dd", "li", "p", "td"],
	
	/**
	 * Names of tags that don’t contain timestamps either directly or
	 * indirectly.
	 */
	codeTags: ["code", "input", "pre", "textarea"],
	
	/**
	 * Regular expression matching all the timestamps inserted by this MediaWiki
	 * installation over the years. This regular expression includes the named
	 * capturing groups `hours`, `minutes`, `day`, `month`, `year`, and
	 * `timezone`.
	 * 
	 * Until 2005:
	 * 	18:16, 23 Dec 2004 (UTC)
	 * 2005–present:
	 * 	08:51, 23 November 2015 (UTC)
	 */
	parseRegExp: /(?<hours>\d\d):(?<minutes>\d\d), (?<day>\d\d?) (?<month>(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*) (?<year>\d{4}) \((?<timezone>UTC)\)/,
	
	/**
	 * UTC offset of the wiki's default local timezone. See
	 * [[mw:Manual:Timezone]].
	 */
	utcOffset: 0,
}, window.LocalComments);

$(function () {
	if (!LocalComments.enabled
		|| LocalComments.excludeNamespaces.indexOf(mw.config.get("wgNamespaceNumber")) !== -1
		|| ["view", "submit"].indexOf(mw.config.get("wgAction")) === -1
		|| mw.util.getParamValue("disable") === "loco")
	{
		return;
	}
	
	var proseTags = LocalComments.proseTags.join("\n").toUpperCase().split("\n");
	// Exclude <time> to avoid an infinite loop when iterating over text nodes.
	var codeTags = $.merge(LocalComments.codeTags, ["time"]).join(", ");
	
	// Look in the content body for DOM text nodes that may contain timestamps.
	// The wiki software has already localized other parts of the page.
	var root = $("#wikiPreview, #mw-content-text")[0];
	if (!root || !("createNodeIterator" in document)) return;
	var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, {
		acceptNode: function (node) {
			// We can’t just check the node’s direct parent, because templates
			// like [[Template:Talkback]] and [[Template:Resolved]] may place a
			// signature inside a nondescript <span>.
			var isInProse = proseTags.indexOf(node.parentElement.nodeName) !== -1
				|| !$(node).parents(codeTags).length;
			var isDateNode = isInProse && LocalComments.parseRegExp.test(node.data);
			return isDateNode ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
		},
	});
	
	/**
	 * Marks up each timestamp found.
	 */
	function wrapTimestamps() {
		var prefixNode;
		while ((prefixNode = iter.nextNode())) {
			var then;
			var dateNode;
			var result = LocalComments.parseRegExp.exec(prefixNode.data);
			if (result) {
				// Split out the timestamp into a separate text node.
				dateNode = prefixNode.splitText(result.index);
				var suffixNode = dateNode.splitText(result[0].length);
				
				// Determine the represented time.
				var components = result.groups;
				var monthIndex = mw.config.get("wgMonthNames").slice(1).indexOf(components.month);
				// Many Wikipedias started out with English as the default
				// localization, so fall back to English.
				if (monthIndex === -1) {
					monthIndex = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].indexOf(components.month);
				}
				if (monthIndex === -1) {
					monthIndex = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(components.month);
				}
				if (monthIndex !== -1) {
					var offsetHours = components.hours - LocalComments.utcOffset;
					var minuteOffset = (LocalComments.utcOffset - Math.round(LocalComments.utcOffset)) % 60;
					var offsetMinutes = components.minutes - minuteOffset;
					then = new Date(Date.UTC(components.year, monthIndex, components.day, offsetHours, offsetMinutes));
				}
			}
			
			// Wrap the timestamp inside a <time> element for findability.
			// This loop must wrap the text in a <time> element no matter what,
			// even if the time is invalid, to avoid an infinite loop as the
			// same node keeps coming up as a candidate that the node iterator
			// thinks is valid.
			// [[User talk:Mxn/CommentsInLocalTime.js#Interface-protected edit request on 18 November 2022]]
			var timeElt = $("<time />");
			if (!isNaN(then)) {
				// MediaWiki core styles .explain[title] the same way as
				// abbr[title], guiding the user to the tooltip.
				timeElt.addClass("localcomments explain");
				timeElt.attr("datetime", then.toISOString());
			}
			if (dateNode) $(dateNode).wrap(timeElt);
		}
	}
	
	/**
	 * Returns the coarsest relative date component that fits within the time
	 * elapsed between the given date and the current date.
	 * 
	 * @param {Date} then The date object before or after the current date.
	 * @returns {Object} An object indicating the date component’s value and
	 *	unit compatible with `Intl.RelativeTimeFormat`.
 	 */
	function relativeDateComponent(then) {
		var now = new Date();
		var value;
		var unit;
		var seconds = (then - now) / 1000; // convert ms to s
		value = seconds;
		unit = "seconds";
		
		var minutes = seconds / 60;
		if (Math.abs(seconds) > 45) { // moment.relativeTimeThreshold("s")
			value = minutes;
			unit = "minutes";
		}
		
		var hours = minutes / 60;
		if (Math.abs(minutes) > 45) { // moment.relativeTimeThreshold("m")
			value = hours;
			unit = "hours";
		}
		
		var days = hours / 24;
		if (Math.abs(hours) > 22) { // moment.relativeTimeThreshold("h")
			value = days;
			unit = "days";
		}
		
		var weeks = days / 7;
		if (Math.abs(days) > 7) {
			value = weeks;
			unit = "weeks";
		}
		
		return {
			value: Math.round(value),
			unit: unit,
		};
	}
	
	/**
	 * Returns a formatted string for the given date object.
	 * 
	 * @param {Date} then The date object to format.
	 * @param {String} fmt A format string or function.
	 * @returns {String} A formatted string.
	 */
	function formatDate(then, fmt) {
		if (fmt instanceof Function) {
			return fmt(then);
		}
		
		var lang = mw.config.get("wgPageViewLanguage");
		var formatter = mw.loader.require("mediawiki.DateFormatter");
		switch (fmt.type) {
			case "absolute":
				if (fmt.options) {
					var absolute = new Intl.DateTimeFormat(lang, fmt.options);
					return absolute.format(then);
				}
				return formatter.formatTimeAndDate(then);
			
			case "relative":
				if (fmt.options) {
					var relative = new Intl.RelativeTimeFormat(lang, fmt.options);
					var component = relativeDateComponent(then);
					return relative.format(component.value, component.unit);
				}
				return formatter.formatRelativeTimeOrDate(then);
			
			case "iso8601":
				return formatter.formatIso(then);
		}
	}
	
	/**
	 * Reformats a timestamp marked up with the <time> element.
	 * 
	 * @param {Number} idx Unused.
	 * @param {Element} elt The <time> element.
	 */
	function formatTimestamp(idx, elt) {
		var iso = elt.dateTime;
		if (!iso) {
			return;
		}
		var then = new Date(Date.parse(iso));
		
		// Add a tooltip with multiple formats.
		elt.title = $.map(LocalComments.tooltipComponents, function (fmt, idx) {
			return formatDate(then, fmt) || "";
		}).join("\n");
		
		// Replace the text.
		var component = relativeDateComponent(then);
		var text;
		if (component.unit === "weeks") {
			text = formatDate(then, {
				type: "absolute",
				options: LocalComments.outputFormats.absolute,
			});
		} else {
			text = formatDate(then, {
				type: "relative",
				options: LocalComments.outputFormats.relative,
			});
		}
		if (text) {
			$(elt).text(text);
		}
		
		// Register for periodic updates.
		$(elt).attr("data-localcomments-unit", component.unit);
	}
	
	/**
	 * Reformat all marked-up timestamps and start updating timestamps on an
	 * interval as necessary.
	 */
	function formatTimestamps() {
		wrapTimestamps();
		$(".localcomments").each(function (idx, elt) {
			// Update every timestamp at least this once.
			formatTimestamp(idx, elt);
			
			if (!LocalComments.dynamic) return;
			
			// Update this minute’s timestamps every second.
			if ($("[data-localcomments-unit='seconds']").length) {
				setInterval(function () {
					$("[data-localcomments-unit='seconds']").each(formatTimestamp);
				}, 1000 /* ms */);
			}
			// Update this hour’s timestamps every minute.
			setInterval(function () {
				$("[data-localcomments-unit='minutes']").each(formatTimestamp);
			}, 60 /* s */ * 1000 /* ms */);
			// Update today’s timestamps every hour.
			setInterval(function () {
				$("[data-localcomments-unit='hours']").each(formatTimestamp);
			}, 60 /* min */ * 60 /* s */ * 1000 /* ms */);
		});
	}
	
	wrapTimestamps();
	mw.loader.using("mediawiki.DateFormatter", function () {
		formatTimestamps();
	});
	
	LocalComments.wrapTimestamps = wrapTimestamps;
	LocalComments.formatTimestamps = formatTimestamps;
});