MediaWiki:Gadget-CommentsInLocalTime.js

From OpenStreetMap Wiki
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * Comments in local time
 * [[w:User:Mxn/CommentsInLocalTime]]
 * 
 * Adjust timestamps in comment signatures to use easy-to-understand, relative
 * local time instead of absolute UTC time.
 * 
 * Inspired by [[w:Wikipedia:Comments in Local Time]].
 * 
 * @author [[User:Minh Nguyen]] ([[w: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, keyed by a few common
	 * cases.
	 * 
	 * If a property of this object is set to a string, the timestamp is
	 * formatted according to the documentation at
	 * <http://momentjs.com/docs/#/displaying/format/>.
	 * 
	 * If a property of this object is set to a function, it is called to
	 * retrieve the formatted timestamp string. See
	 * <http://momentjs.com/docs/#/displaying/> for the various things you can
	 * do with the passed-in moment object.
	 */
	formats: {
		/**
		 * Within a day, show a relative time that’s easy to relate to.
		 */
		day: function (then) { return then.fromNow(); },
		
		/**
		 * Within a week, show a relative date and specific time, still helpful
		 * if the user doesn’t remember today’s date. Don’t show just a relative
		 * time, because a discussion may need more context than “Last Friday”
		 * on every comment.
		 */
		week: function (then) { return then.calendar(); },
		
		/**
		 * The calendar() method uses an ambiguous “MM/DD/YYYY” format for
		 * faraway dates; spell things out for this international audience.
		 */
		other: function (then) {
			var pref = mw.user.options.values.date;
			return then.format(window.LocalComments.formatOptions[pref] || "LLL");
		},
	},
	
	/**
	 * Formats to display in each timestamp’s tooltip, one per line.
	 * 
	 * If an element of this array is a string, the timestamp is formatted
	 * according to the documentation at
	 * <http://momentjs.com/docs/#/displaying/format/>.
	 * 
	 * If an element of this array is a function, it is called to retrieve the
	 * formatted timestamp string. See <http://momentjs.com/docs/#/displaying/>
	 * for the various things you can do with the passed-in moment object.
	 */
	tooltipFormats: [
		function (then) { return then.fromNow(); },
		"LLLL",
		"YYYY-MM-DDTHH:mmZ",
	],
	
	/**
	 * When true, this gadget refreshes timestamps periodically.
	 */
	dynamic: true,
}, {
	// SITE OPTIONS ////////////////////////////////////////////////////////////
	
	/**
	 * Numbers of namespaces to completely ignore.
	 * 
	 * <https://wiki.openstreetmap.org/w/api.php?action=query&meta=siteinfo&siprop=namespaces&formatversion=2>
	 */
	excludeNamespaces: [-1, 0, 8, 120, 122],
	
	/**
	 * 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"],
	
	/**
	 * An object mapping the date format user options provided by this MediaWiki
	 * installation to corresponding Moment.js format strings. The user can
	 * choose a preferred date format in
	 * [[Special:Preferences#mw-prefsection-rendering-dateformat]]. See
	 * [[mw:Manual:Date formatting]]. These formats determine the default
	 * timestamp display format.
	 * 
	 * These formats come from
	 * <https://doc.wikimedia.org/mediawiki-core/1.34.0/php/MessagesEn_8php.html#a2fc93ea5327f655d3ed306e221ee33f0>.
	 * When customizing these formats for a different wiki’s content language,
	 * consult the language’s corresponding message file’s `$dateFormats`
	 * variable. Use only the messages with the “both” suffix, and remove that
	 * suffix from each key. The MediaWiki date format syntax is described in
	 * <https://doc.wikimedia.org/mediawiki-core/1.34.0/php/classLanguage.html#a94f84f82d7f954c4cb2e191d22c6e6a6>
	 * and [[mw:Help:Extension:ParserFunctions##time]]. The Moment.js syntax is
	 * described in <https://momentjs.com/docs/#/parsing/string-format/>.
	 * 
	 * @todo Automatically convert MediaWiki date format syntax to Moment.js
	 *		 date format syntax.
	 */
	formatOptions: {
		mdy: "HH:mm, MMMM D, YYYY", // H:i, F j, Y
		dmy: "HH:mm, D MMMM YYYY", // H:i, j F Y
		ymd: "HH:mm, YYYY MMMM D", // H:i, Y F j
		"ISO 8601": "YYYY-MM-DDTHH:mm:ss", // xnY-xnm-xnd"T"xnH:xni:xns
	},
	
	/**
	 * Expected format or formats of the timestamps in existing wikitext. If
	 * very different formats have been used over the course of the wiki’s
	 * history, specify an array of formats.
	 * 
	 * This option expects parsing format strings
	 * <http://momentjs.com/docs/#/parsing/string-format/>.
	 */
	parseFormat: "H:m, D MMM YYYY",
	
	/**
	 * Regular expression matching all the timestamps inserted by this MediaWiki
	 * installation over the years. This regular expression should more or less
	 * agree with the parseFormat option.
	 * 
	 * 2005–2012:
	 * 	11:22, 29 October 2010 (BST)
	 * 	14:48, 2 November 2010 (UTC)
	 * 2013–present:
	 * 	08:51, 23 November 2015 (UTC)
	 */
	parseRegExp: /\d\d:\d\d, \d\d? (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w* \d{4} \((UTC|BST)\)/,
	
	/**
	 * 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: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 result = LocalComments.parseRegExp.exec(prefixNode.data);
			if (!result) continue;
			
			// Split out the timestamp into a separate text node.
			var dateNode = prefixNode.splitText(result.index);
			var suffixNode = dateNode.splitText(result[0].length);
			
			// Determine the represented time.
			// Timestamps are always in English regardless of the user’s
			// preferred language or the page’s content language.
			var then = moment.utc(result[0], LocalComments.parseFormat, "en");
			
			// 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.
			// [[w:User talk:Mxn/CommentsInLocalTime.js#Interface-protected edit request on 18 November 2022]]
			var timeElt = $("<time />");
			if (then.isValid()) {
				then.utcOffset(-LocalComments.utcOffset);
				if (result[1] == "BST") {
					then.utcOffset("+01:00");
				} else {
					then.utcOffset(-LocalComments.utcOffset);
				}
				// 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());
			}
			$(dateNode).wrap(timeElt);
		}
	}
	
	/**
	 * Returns a formatted string for the given moment object.
	 * 
	 * @param {Moment} then The moment object to format.
	 * @param {String} fmt A format string or function.
	 * @returns {String} A formatted string.
	 */
	function formatMoment(then, fmt) {
		return (fmt instanceof Function) ? fmt(then) : then.format(fmt);
	}
	
	/**
	 * 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).attr("datetime");
		var then = moment(iso, moment.ISO_8601);
		var now = moment();
		var withinHours = Math.abs(then.diff(now, "hours", true))
			<= moment.relativeTimeThreshold("h");
		var formats = LocalComments.formats;
		var text;
		if (withinHours) {
			text = formatMoment(then, formats.day || formats.other);
		}
		else {
			var dayDiff = then.diff(moment().startOf("day"), "days", true);
			if (dayDiff > -6 && dayDiff < 7) {
				text = formatMoment(then, formats.week || formats.other);
			}
			else text = formatMoment(then, formats.other);
		}
		$(elt).text(text);
		
		// Add a tooltip with multiple formats.
		elt.title = $.map(LocalComments.tooltipFormats, function (fmt, idx) {
			return formatMoment(then, fmt);
		}).join("\n");
		
		// Register for periodic updates.
		var withinMinutes = withinHours
			&& Math.abs(then.diff(now, "minutes", true))
				<= moment.relativeTimeThreshold("m");
		var withinSeconds = withinMinutes
			&& Math.abs(then.diff(now, "seconds", true))
				<= moment.relativeTimeThreshold("s");
		var unit = withinSeconds ? "seconds" :
			(withinMinutes ? "minutes" :
				(withinHours ? "hours" : "days"));
		$(elt).attr("data-localcomments-unit", unit);
	}
	
	/**
	 * Reformat all marked-up timestamps and start updating timestamps on an
	 * interval as necessary.
	 */
	function formatTimestamps() {
		$(".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 */);
		});
	}
	
	mw.loader.using("moment", function () {
		if (window.convenientDiscussions) {
			// [[Commons:User:Jack who built the house/Convenient Discussions#Compatibility]]
			mw.hook("convenientDiscussions.commentsReady").add(function () {
				wrapTimestamps();
				formatTimestamps();
			});
		} else {
			wrapTimestamps();
			formatTimestamps();
		}
	});
});