User:Yair rand/DiffLists.js

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.
/* globals:$, mw, DiffListsLoaded */

"use strict";

// Changes appearance of Recent Changes, Watchlist, Contributions, History 
// pages, and Related Changes.
// Also adds filter options.
// This script is intended to demonstrate my proposal for watchlist design, proposed in T121361.
//
// Intended browser support: Modern browsers.

// TODO: Maintain all non-autogenerated edit summary bits. #autolist is being removed.
// TODO: More advanced filtering options. Things like Wikiproject prop groups.
// ** Allow whatever caps
// TODO: Default (when no stored settings) to Babel settings (not in links), if possible. Investigate.

// Issue: When on non-WD sites, the hiding system can hide grouped diffs if there are both
// edits to the wiki page and WD edits that are hidden by prefs.

function DiffLists() {

if ( window.DiffListsLoaded ) {
	return;
}

window.DiffListsLoaded = true;

var isWD = mw.config.get( 'wgDBname' ) === 'wikidatawiki',
	wdDomain = 'https://www.wikidata.org',
	api = isWD ? new mw.Api() : new mw.ForeignApi( wdDomain + '/w/api.php' );

( 
	( [ "Recentchanges", "Watchlist", "Recentchangeslinked", "Contributions" ] )
		.indexOf( mw.config.get( "wgCanonicalSpecialPageName" ) ) !== -1 ||
	( mw.config.get( "wgAction" ) === "history" && [ 0, 120 ].indexOf( mw.config.get( "wgNamespaceNumber" ) ) !== -1 )
) && api.get( {
	action: "query",
	meta: "allmessages",
	amlang: mw.config.get( 'wgUserLanguage' ),
	ammessages: [
		// Changing the order shouldn't break things. TODO: Fix.
		// Diff view stuff.
		"wikibase-diffview-label", 
		"wikibase-diffview-description",
		"wikibase-diffview-alias",
		"wikibase-diffview-link",
		"wikibase-entity-property",
		"wikibase-diffview-qualifier",
		"wikibase-diffview-reference",
		"wikibase-diffview-rank",
		// Extras
		"wikibase-entitytermsforlanguagelistview-label",
		"wikibase-entitytermsforlanguagelistview-description",
		"wikibase-statementsection-statements",
		"wikibase-diffview-rank-preferred",
		"wikibase-diffview-rank-normal",
		"wikibase-diffview-rank-deprecated",
		"wikibase-diffview-link-badges"
	].join( "|" ),
	maxage:  60 * 60 * 24 * 30,
	smaxage: 60 * 60 * 24 * 30
} ).done( function ( msgData ) {
	$( function () {
		//console.log( "msgs", msgData );
		mw.util.addCSS( 
			".YR-cl-added { background-color: #d8ecff; margin: 0 1px; padding: 0 1px; }" + 
			".YR-cl-removed { background-color: #feeec8; margin: 0 1px; padding: 0 1px; text-decoration: line-through; }" +
			".YR-cl-removed a .diffchange-inline { text-decoration: underline; }" +
			// Yeah, this is misusing !important, but I need to override jquery's straight style.display.
			".YR-cl-hidden { display: none !important; }" +
			".YR-cl-summary ~ .YR-cl-summary:before { content: \", \"; }" +
			".YR-cl-hiddensummary { display: none; }" +
			// The byte markers are even more useless when the diff visible right there. Hide them.
			".YR-cl-enhanceddiff .mw-diff-bytes, .YR-cl-enhanceddiff .mw-diff-bytes + .mw-changeslist-separator, " +
				".YR-cl-enhanceddiff .mw-changeslist-line-inner-characterDiff" +
				"{ display: none; }"
		);
		const 

			lang = mw.config.get( 'wgUserLanguage' ),

			/**
			 * Whether the user has "Group changes by page in recent changes and watchlist"
			 * turned on in their preferences.
			 * @type {'1'|0}
			 */
			groupChangesUserPreference = mw.user.options.get( 'usenewrc' ),

			/** @type {string[]} */
			msgArray = msgData.query.allmessages.map( msg => msg[ "*" ] ),
			/**
			 * @type { { [msgName:string]: string } }
			 */
			msgs = Object.fromEntries( msgData.query.allmessages.map( function ( msg, i ) {
				return [
					i < 8 ? msg.name.split( "-" )[ 2 ] : msg.name,
					msg[ "*" ]
				];
			} ) ),

			allPropTypes = { 
				[ msgs.label ]: "Labels", 
				[ msgs.description ]: "Descriptions", 
				[ msgs.link ]: "Sitelinks", 
				[ msgs.alias ]: "Aliases", 
				[ msgs.property ]: msgs[ "wikibase-statementsection-statements" ]
			},

			// (These indexes must match the msg api query order.)
			LABEL = 0,
			DESCRIPTION = 1,
			ALIAS = 2,
			LINK = 3,
			PROPERTY = 4,


			listType = mw.config.get( "wgAction" ) === "history" ? "history" : 
				( mw.config.get( "wgCanonicalSpecialPageName" ) === "Contributions" ?
					"contribs" :
					// Meaning watchlist, recent changes, or related changes.
					"normal"
				),
			// Selector for all the individual links to particular diffs.
			// (Not including "grouped" diffs.)
			changeSelector = 
				isWD ?
					listType === "history" ? 
						// History pages...
						".mw-history-histlinks > span + span > a"
						//".mw-history-histlinks a:last-child"
						:
						listType === "contribs" ?
							// Contributions pages...
							".mw-changeslist-diff"
							//".mw-contributions-list a"
							:
							// Normal watchlists/Recent changes
							".mw-changeslist .special > li > .mw-changeslist-line-inner > a:first-child, " + 
							// Enhanced watchlists/Recent changes
							
							// TODO: This is no longer accurate. difflinks are now .mw-changeslist-diff.
							// Groups are .mw-changeslist-groupdiff
							
							
							//".mw-enhanced-rc .mw-title + a, " + 
							".mw-changeslist-diff"
					:
					// '.mw-changeslist .wikibase-edit a[tabindex]';
					".mw-changeslist .mw-changeslist-src-mw-wikibase a[tabindex]";

		var 
			createChangeBlock = ( function () {
				var addedTemplate   = $( "<span>" ).addClass( "YR-cl-added"   )[ 0 ],
					removedTemplate = $( "<span>" ).addClass( "YR-cl-removed" )[ 0 ];
				
				/**
				 * Wrap an element in a "added"/"removed" marker, with the relevant styles.
				 * @param {"added"|"removed"} type
				 * @param {Element|Text} elem The element to wrap.
				 * @return {Element} The new outer, wrapping element.
				 */
				return function ( type, elem ) { 
					var result = ( type === "added" ? addedTemplate : removedTemplate ).cloneNode( true );
					result.appendChild( elem );
					return result;
					// return ( type === "added" ? addedTemplate : removedTemplate ).cloneNode( true ).appendChild( elem ).parentNode;
				};
			})();
		
		var
			/**
			 * Data for every edit listing on the page.
			 * @type {Diff[]}
			 */
			 allDiffs = [],
			 /**
			  * For those with "Group changes by page in recent changes and watchlist" enabled in
			  * their preferences, some diffs are nested under a "group" diff's element.
			  * To reconstruct the group diff from the component diffs, this map records
			  * each group diff element's set of changes.
			  * @type {Map<Element, { markers: { timestamp:string, firstElem: Element }[], diff: Diff }>}
			  */
			 nestedParents = new Map();

		/**
		 * @typedef {object} Diff
		 * Data relating to a particular diff.
		 * @property {Element} elem The edit listing itself.
		 * @property {Chunk[]} chunks Elements containing summaries of the diff.
		 * @property { DiffComponents } components
		 */

		/**
		 * @typedef { [
		 * 		labels:OtherChanges, descriptions:OtherChanges, aliases:AliasChanges, links:SiteLinkChanges,
		 * 		properties:{ [propertyKey:string]: StatementChange }
		 * ] } DiffComponents
		 * A set of all the individual changes that were done in the edit,
		 * sorted by category.
		 */
		/**
		 * @typedef StatementChange
		 * List of changes done within a specific property.
		 * @property { { [value:string]: StatementChangeValue } } values
		 * @property {Element} link A link to the statement's property.
		 */
		/**
		 * @typedef StatementChangeValue
		 * @property { "removed" | "added" } [change] (Undefined if the mainsnak is unchanged.)
		 * @property {Element} node Representing the value of the mainsnak.
		 * @property {( { propLink: Element, value: Element }[] & { change: "removed" | "added" } )[]} [references]
		 * @property { { [qualPropName:string]: { change: "removed" | "added", value: Element }[] & { link: Element } } } [qualifiers]
		 * @property { { removed?: { rank: number, rankName: string }, added?: { rank: number, rankName: string } } } [rank]
		 */

		/**
		 * @typedef { { [langOrWiki:string]: { removed?: Element, added?: Element }[] } } AliasChanges
		 * A change to a set of aliases. Multiple additions in the same language can happen in the same
		 * diff, so each language's changes work as an array.
		 */

		/**
		 * 
		 * @typedef { { [site:string]: { added?: Element, removed?: Element, badges?: { added?: Element, removed?: Element }[] } } } SiteLinkChanges
		 * The site is the 'site code' (eg 'enwiki'). Each sitelink can have a set of badges.
		 */

		/**
		 * @typedef { { [langcode:string]: { added?: Element, removed?: Element, modified?: Element } } } OtherChanges
		 * A change to a label or description.
		 */

		/**
		 * @typedef {object} Chunk
		 * @property {Element} elem DOM element with a summary of the changes.
		 * @property {(LABEL|DESCRIPTION|LINK|ALIAS|PROPERTY)} type
		 * @property {string} data For statements: Property. For links: site. Others: Language code.
		 * @property {SiteLinkChanges|AliasChanges|OtherChanges|{ [propertyKey:string]: StatementChange }} change
		 * The diff component that the chunk was built from.
		 * @property {Element} [wrapper] Span wrapping around the content. This element's display style
		 * is toggled to show or hide the chunk. (Property set in addChunks().)
		 */

		/**
		 * Set up the entire preferences system for hiding certain edits or parts of edits.
		 * Returns a function for updating whether a set of diffs is displayed.
		 */
		function prepareHidingPreferences() {

			var filterData = {
				dbTypes: [ '', 'voyage', 'books', 'quote', 'news', 'source', 'versity' ].map( x => 'wiki' + x ),
				specialwikis: [ 'commons', 'meta', 'mediawiki', 'wikidata', 'species' ],
				propGroups: {
					// These can be functions or arrays.
					identifiers: function ( x ) {
						var v = x.change.values, node;
						for ( var i in v ) {
							node = v[ i ].node;
							if ( node?.nodeType === 1 && (
								node.classList.contains( 'wb-external-id' ) ||
								node.firstElementChild?.classList.contains( 'wb-external-id' )
							) ) {
								return true;
							}
						}
					},
					other: function ( x ) {
						return filterData.specialwikis.some( y => y + 'wiki' === x.data );
					}
				}
			};


			// Settings will be stored in localStorage, in the following format:
			// { "label": "..." | "" | false, ... }
			var
				/**
				 * Currently formatted as, eg, { "label": "en|fr", "description": "", ... }
				 * only, it uses the internationalized forms of "label" etc, which it _really_
				 * shouldn't.
				 * TODO: Fix that.
				 * TODO: Move into the scope of buildOptionsBox.
				 * An empty string means show everything.
				 * A value of "false" for a category means hide everything.
				 */
				simpleSettings = getSettings(),
				processedSettings = processSettings( simpleSettings );



			/**
			 * Build the area for editing user preferences.
			 */
			function buildOptionsBox() {
				function setSettings() {
					var data = {};
					$.each( checkboxes, function ( i, checkbox ) {
						data[ i ] = checkbox.checked && inputs[ i ].value;
					} );
					simpleSettings = data;
					localStorage[ "YR-cl-settings" ] = JSON.stringify( simpleSettings );
					processedSettings = processSettings( simpleSettings );
				}
				
				var optionsContainer,
					optionsDiv = document.createElement( "div" ),
					/** @type {Object.<number,HTMLInputElement>} */
					checkboxes = {},
					inputs = {};
					
				if ( listType === "normal" ) {
					optionsContainer = document.querySelector( "#mw-watchlist-options, .rcoptions, .rcfilters-head, .mw-rcfilters-head" );
					// Add a divider.
					optionsContainer.appendChild( document.createElement( "hr" ) ).style.margin = "8px 0";
				} else {
					// var l = document.querySelector( listType === "contribs" ? ".mw-contributions-form" : "#mw-history-searchform" );
					var l = document.querySelector( ".mw-htmlform-ooui-wrapper" );
					if ( l ) {
						optionsContainer = l.parentNode.insertBefore( document.createElement( "fieldset" ), l.nextSibling );
						optionsContainer
							.appendChild( document.createElement( "legend" ) )
								.append( "Filter options" );
					}
				}
				
				// Build filter options menu
				$.each( allPropTypes, function ( type, optionsLabel ) {

					// var typeIndex = msgArray.indexOf( type );

					var optionDiv = document.createElement( "div" );
					optionDiv.style.display = "inline-block";
					optionsDiv.appendChild( optionDiv );
					var checkbox = checkboxes[ type ] = document.createElement( "input" );
					checkbox.type = "checkbox";
					checkbox.checked = ( !simpleSettings || simpleSettings[ type ] !== false ) ? true : false;
					var input = inputs[ type ] = document.createElement( "input" );
					optionDiv.append(
						checkbox,
						optionsLabel + ": ",
						input
					);
					
					if ( [ msgs.label, msgs.description, msgs.alias ].includes( type ) ) {
					// if ( [ LABEL, DESCRIPTION, ALIAS ].includes( typeIndex ) ) {
						input.title = 
							'Show only languages with these languages codes, ' + 
							'separated by |, e.g. "en|fr". Leave blank to show all.';
					} else if ( type === msgs.link ) {
						input.title = 
							'Show only links to projects with these database ' + 
							'codes, separated by |,  e.g. "enwiki|frwikivoyage". ' +
							'Leave blank to show all.\n' +
							'You can use a language code to show links to all ' +
							'projects in that language, or a project database ' +
							'suffix to show links to all of those projects ' +
							'regardless of language. Use "other" to show links ' +
							'to Commons, Meta, Mediawiki, Wikispecies, and ' +
							'Wikidata.';
					} else {
						input.title =
							'Show only changes to statements using these ' + 
							'properties, separated by |, e.g. "P40|P569". ' +
							'Leave blank to show all.\n' +
							'You can use "identifiers" to select all identifier ' +
							'properties, and prefix a property or group of ' +
							'properties with "^" to not show the property. ' +
							'(E.g. Input just "^identifiers" to show all ' +
							'properties except identifiers.)';
					}
					input.style.width = "50px";
					input.value = simpleSettings && simpleSettings[ type ] || "";
					if ( simpleSettings && simpleSettings[ type ] === false ) {
						input.disabled = true;
					}
					checkbox.onchange = input.onchange = function () {
						input.disabled = !checkbox.checked;
						setSettings();
						updateDisplay( allDiffs );
					};
					input.oninput = function ( e ) {
						setSettings();
						updateDisplay( allDiffs );
					}
					input.onkeydown = function ( e ) {
						if ( e.key === "Enter" ) {
							setSettings();
							updateDisplay( allDiffs );
							return false;
						}
					};
				} );
				
				// Add the menu to the DOM
				if ( optionsContainer ) {
					optionsContainer.appendChild( optionsDiv );
				}
			}
			
			// Multiple situations possible here:
			// * Checking to see if any members of a group match show=true
			// ** 'changes' obj, check to see if there's any of x type, etc
			// * Check if a particular prop/value should be shown
			// Among this, there can be single setting prop or multi-setting prop.
			// Is it actually necessary to special-case the first check?
			//
			/**
			 * Checks if a value should be shown, under the user's current preferences.
			 * @param {Chunk} value 
			 * @returns {boolean} 
			 */
			function checkSetting( value ) {
				var setting = processedSettings[ msgArray[ value.type ] ];
				
				if ( setting === false ) {
					// The entire category is unchecked. Hide all.
					return false;
				} else if ( setting === '' || setting === undefined ) {
					// Preferences haven't been filled in. Show by default.
					return true;
				} else {
					var shouldBeVisible = !setting[ 0 ].additive,
						propGroups = filterData.propGroups;
					setting.forEach( settingV => {
						if ( settingV.type === 'plain' ) {
							if ( value.data === settingV.content ) {
								shouldBeVisible = settingV.additive;
							}
						} else if ( settingV.type === 'group' ) {
							// Probably statements only
							if ( typeof propGroups[ settingV.content ] === 'function' ?
								propGroups[ settingV.content ]( value ) :
								propGroups[ settingV.content ].indexOf( value.data ) !== -1
							) {
								shouldBeVisible = settingV.additive;
							}
						} else if ( settingV.type === 'lang' ) {
							// Links only
							// I think this needs access to dbtypes, to deal with unusual cases.
							if ( value.data.startsWith( settingV.content ) ) {
								if ( filterData.dbTypes.some( x => settingV.content + x === value.data ) ) {
									shouldBeVisible = settingV.additive;
								}
							}
						} else if ( settingV.type === 'wiki' ) {
							// Links only
							if ( value.data.endsWith( settingV.content ) ) {
								shouldBeVisible = settingV.additive;
							}
						} else if ( settingV.type === 'specialwiki' ) {
							// Links to "other" wikis, eg Commons.
							if ( value.data === settingV.content + 'wiki' ) {
								shouldBeVisible = settingV.additive;
							}
						}
					} );
					return shouldBeVisible;
				}
			}


			
			/**
			 * Retrieve user preferences from localStorage or URL settings.
			 * Each value is plain strings straight from the options boxes
			 * (or equivalents) unparsed.
			 * @return {Object<string,string|false>|false}
			 */
			function getSettings() {
				var urlArgs = mw.util.getParamValue( 'difflists' ),
					storage = localStorage[ "YR-cl-settings" ];
				// 
				if ( !urlArgs && !storage ) {
					return false;
				} else {
					return $.extend( {}, 
						storage && JSON.parse( storage ),
						urlArgs && JSON.parse( urlArgs ) );
				}
			}

			/**
			 * 
			 * @param {Object<string, string>|false} simpleSettings 
			 * @returns {false|Object<string,
			 * 		{
				* 		additive: boolean,
				* 		type: "plain"|"group"|"lang"|"wiki"|"specialwiki",
				* 		content: string
			 * 		}[]|
			 * 		false
			 * >}
			 */
			function processSettings( simpleSettings ) {
				var settings = {},
					{ dbTypes, propGroups, specialwikis } = filterData;
				
				if ( simpleSettings === false ) {
					// No hide settings. Show everything.
					return false;
				}
				
				$.each( simpleSettings, function ( type, input ) {
					var setting = settings[ type ] = input && input.split( /\s*[\|\,]\s*/ );
					if ( setting ) {
						settings[ type ] = setting.map( x => {
							var isProp = type === msgs.property,
								isLink = type === msgs.link,
								additive = x[ 0 ] !== '^',
								content = additive ? x : x.slice( 1 );
							if ( isProp && !propGroups[ content ] ) {
								content = 'Property:' + content;
							}
							return {
								additive,
								type: 
									// What was this line intended to do?
									// Doesn't seem to make any sense?
									( propGroups.hasOwnProperty( content ) && 'group' ) ||
									( isLink && (
										( specialwikis.indexOf( x ) !== -1 && 'specialwiki' ) ||
										( dbTypes.indexOf( x ) !== -1 && 'wiki' ) ||
										( !dbTypes.some( y => x.endsWith( y ) ) && 'lang' )
									) ) || 
									'plain',
								content
							};
						} );
					}
					
					// Values:
					// false - hide the entire category
					// { additive: true, type: plain, content: en }
					// { additive: true, type: lang, content: en }
					// { additive: true, type: wiki, content: wiki }
					// { additive: false, type: group, content: identifiers }
					// { additive: true, type: specialwiki, content: meta }
					
				} );
				return settings;
			}


			
			/** 
			 * Show or hide diff, depending on filter preferences.
			 * @param {Diff[]} diffs 
			 */
			function updateDisplay( diffs ) {
				var isHistory = listType === "history";
				
				processedSettings && diffs.forEach( function ( diff ) {
					
					/** 
					 * The diff should be hidden, unless at least one chunk
					 * needs to be shown
					 */
					var displayDiff = false;
					
					// If there's some not filtered, or we're on a history page
					// where all diffs are shown, hide individual filtered segments.
					$.each( diff.chunks, function ( i, chunk ) {
						var display = checkSetting( chunk );
						
						displayDiff ||= display;
						
						chunk.wrapper.className = display ? "YR-cl-summary" : "YR-cl-hiddensummary";
					} );
					
					// If all elements are filtered, hide the whole listing, unless
					// we're on a history page.
					if ( !isHistory ) {
						diff.elem.classList.toggle( "YR-cl-hidden", !displayDiff );
					}
					
				} );
			}

			buildOptionsBox();

			return updateDisplay;

		}



		/**
		 * Takes two td elements with text content, and 'merges' the diff parts to show
		 * added and removed text in the same string.
		 * @param {Element} rm 
		 * @param {Element} add 
		 * @returns {Element}
		 */
		function mergeChangesDom( rm, add ) {
			rm = rm.firstChild;
			add = add.firstChild;
			let newDom = document.createElement( 'span' );
			for ( let curRm = rm.firstChild, curAdd = add.firstChild; curRm || curAdd; ) {
				let rmIsElem = curRm?.nodeType === 1,
					addIsElem = curAdd?.nodeType === 1;
				if ( !curRm || !curAdd ) {
					newDom.appendChild( createChangeBlock( curRm ? 'removed' : 'added', curRm || curAdd ) );
				} else if ( rmIsElem || addIsElem ) {
					if ( rmIsElem ) {
						newDom.appendChild( createChangeBlock( 'removed', curRm ) );
					}
					if ( addIsElem ) {
						newDom.appendChild( createChangeBlock( 'added', curAdd ) );
					}
				} else {
					let rmText = curRm.nodeValue,
						addText = curAdd.nodeValue;
					if ( rmText === addText ) {
						newDom.appendChild( curRm );
						add.removeChild( curAdd );
					} else if ( addText.startsWith( rmText ) ) {
						newDom.appendChild( curRm );
						curAdd.nodeValue = addText.slice( rmText.length );
					} else if ( rmText.startsWith( addText ) ) {
						newDom.appendChild( curAdd );
						curRm.nodeValue = rmText.slice( addText.length );
					} else {

						// Things don't match up. Might be because of weirdness
						// with handling spaces.
						// Trim the texts, and try again.
						var tRmText = rmText.trim(),
							tAddText = addText.trim();
						if ( tRmText !== rmText || tAddText !== addText ) {
							curRm.nodeValue = tRmText;
							curAdd.nodeValue = tAddText;
						} else {
							// It didn't work.
							// No idea what could cause this, but just in case...
							newDom.append( '[error]' );
							add.removeChild( curAdd );
							rm.removeChild( curRm );
						}
					}
				}
				curRm = rm.firstChild;
				curAdd = add.firstChild;
			}
			return newDom;
		}
		
		/**
		 * Build 'chunks' (dom containing summaries) for these diff components.
		 * @param {DiffComponents} changes The parsed diff's components, to base the chunks off of.
		 * @return {Chunk[]} 
		 */
		function buildChunks( changes ) {

			/** @type {Chunk[]} */
			var chunks = [];

			// First non-statement changes.
			
			[ LABEL, DESCRIPTION, LINK, ALIAS ].forEach( function ( type ) {
				$.each( changes[ type ], function ( langOrWiki, change ) {
					var elem = document.createElement( "span" ),
						text = ( { 
							[ LABEL ]: msgs[ "wikibase-entitytermsforlanguagelistview-label" ], 
							[ DESCRIPTION ]: msgs[ "wikibase-entitytermsforlanguagelistview-description" ], 
							[ LINK ]: "Link", 
							[ ALIAS ]: "Aliases" 
						} )[ type ];
					chunks.push( { elem, type, data: langOrWiki, change } );
					elem.append( text + " [" + langOrWiki + "]: " );
					
					$.each( type === ALIAS ? change : [ change ], function( i, a ) {
						[ "added", "removed" ].forEach( function ( type ) {
							a[ type ] && elem.appendChild( createChangeBlock( type, a[ type ] ) );
						} );
						if ( a.modified ) {
							elem.appendChild( a.modified );
						}
					} );
					
					// For links, add badge DOM.
					if ( 'badges' in change ) {
						change.badges.forEach( function ( badgeChange ) {
							$.each( badgeChange, function ( addedOrRemoved, badgeElem ) {
								// We're using the original element. Should this
								// be cloned? If not, this might cause problems
								// for multiple diffs using the same changes.
								badgeElem.prepend( "(badge: " );
								badgeElem.append( ")" );
								elem.appendChild( createChangeBlock( addedOrRemoved, badgeElem ) );
							} );
						} );
					}
				} );
			} );
			
			// Then statement changes.
			$.each( changes[ PROPERTY ], function ( property, change ) {
				var elem = document.createElement( "span" ),
					keys = Object.keys( change.values ),
					// If there's only a single statement being added or removed for
					// this property, encase also the property name within the block.
					encase = keys.length === 1 && change.values[ keys[ 0 ] ].change;
				elem.append(
					change.link,
					": "
				);
				$.each( change.values, function ( valueKey, statement ) {
					if ( statement.change ) {
						if ( !encase ) {
							// Color the mainsnak's value.
							elem
								.appendChild( createChangeBlock( statement.change, statement.node ) );
						} else {
							// Wrap the whole thing (including property) in a
							// 'added'/'removed' color.
							elem.appendChild( statement.node );
							elem = createChangeBlock( statement.change, elem );
						}
					} else {
						// Leave the prop and value uncolored. The only changes are to
						// qualifiers/references/ranks. 
						elem.appendChild( statement.node );
					}
					if ( statement.qualifiers ) {
						
						var first = true;
						// The diff system does something really weird with multis.
						// Deletes and recreates all the same qualifiers. 
						$.each( statement.qualifiers, function ( i, qualPropSet ) {
							elem.append( first ? " / " : ", " );
							var link = qualPropSet.link, // Link to the qualifier's property

								qualHolder = qualPropSet.length === 1 ? 
									elem.appendChild( createChangeBlock( qualPropSet[ 0 ].change, link ) ) : 
									elem;

							first = false;

							qualHolder.append(
								link,
								": "
							);
							qualPropSet.forEach( function ( a ) {
								elem.appendChild( createChangeBlock( a.change, a.value ) );
							} );
						} );
					}
					if ( statement.references ) {
						
						$.each( statement.references, function ( i, reference ) {
							var refElem = createChangeBlock( reference.change, document.createTextNode( "Reference: " ) );
							reference.forEach( function ( refSnak, i ) {
								refElem.append(
									i === 0 ? "" : ", ",
									refSnak.propLink,
									": ",
									refSnak.value
								);
							} );
							elem.append(
								i ? ", " : " / ",
								refElem
							);
						} );
					}
					if ( statement.rank ) {
						$.each( statement.rank, function ( addedOrRemoved, rankChange ) {
							var rankDot = document.createElement( [ "sup", "span", "sub" ][ rankChange.rank ] );
							rankDot.append( "·" );
							rankDot.title = rankChange.rankName;
							statement.node.before(
								createChangeBlock( addedOrRemoved, rankDot )
							);
						} );
					}
				} );
				
				if ( !isWD ) {
					$( elem ).find( 'a[ href ]:not( [ href ^= "http" ] )' ).each( function () {
						this.href = wdDomain + this.getAttribute( 'href' );
					} );
				}
				
				chunks.push( { elem, type: PROPERTY, data: property, change } );
			} );
			return chunks;
		}
		
		/**
		 * Edit the DOM, fill in with the summarized diffs.
		 * @param {Chunk[]} chunks 
		 * @param {Element} outerElement 
		 * @param {{ insertBefore?: Element, markers: { timestamp: number, firstElem: Element }[] } } [nestedChunkData]
		 */
		function addChunks( chunks, outerElement, nestedChunkData ) {
			// TODO: Reuse summary nodes in nesteds where this'll run multiple times.
			var summaryNode = document.createElement( "span" ),
				originalEditSummary = outerElement.querySelector( ".comment" ),
				beforePoint,
				isFirstPassthrough = true;
			
			// For parents of nested diffs, add new chunks to old summary.
			if ( nestedChunkData ) {
				var markers = nestedChunkData.markers;

				// If there's an immediately-following chunk present (note that parts
				// is sorted chronologically by diff timestamp), put the new one
				// immediately before it.
				beforePoint = nestedChunkData.insertBefore;

				if ( markers.length !== 0 ) {
					// This isn't the first time we've done stuff to this listing.
					isFirstPassthrough = false;
					// Use an existing generated summary element if there is one.
					summaryNode = markers[ 0 ].firstElem.parentNode.parentNode;
				}
			}

			if ( isFirstPassthrough ) {
				summaryNode.append( " - " );
			}
			
			// Place the chunks in the proper location.
			chunks.forEach( function ( chunk ) {
				var elem = chunk.elem,
					wrapper = chunk.wrapper = document.createElement( "span" );
				wrapper.className = "YR-cl-summary";
				wrapper.appendChild( elem );

				if ( beforePoint ) {
					beforePoint.before( wrapper );
				} else {
					summaryNode.appendChild( wrapper );
				}
			} );
			
			// Replace old summary with new summary.
			if ( originalEditSummary && isFirstPassthrough ) {
				originalEditSummary.before( summaryNode );

				// If the original edit summary just an auto-generated summary (which
				// is worse than the one we built here), hide it.
				// (This should leave things like "#autolist2", if possible.)
				if ( originalEditSummary.querySelector( '.autocomment' ) ) {
					originalEditSummary.style.display = 'none';
				}
			} else {
				// ummm, somehow there's no .comment. Put it at the end?
				// (not a good solution, as outerElement will sometimes be a tr.)
				outerElement.appendChild( summaryNode );
			}
			
		}
		

		/**
		 * Parse the diff's HTML into a more usable format.
		 * @param {string} diffHtml 
		 * @returns {DiffComponents}
		 */
		function parseDiff( diffHtml ) {

			/**
			 * The diff page's DOM.
			 * @type {JQuery<Element>}
			 */
			var $x = $( diffHtml ),
				/**
				 * @type {DiffComponents}
				 */
				diffComponents = [ {}, {}, {}, {}, {} ];
			
			// Build diffComponents from result data.
			$x.each( function ( trIndex, tr ) {
				if ( tr.nodeName === "TR" && tr.nextSibling && tr.childNodes.length === 2 && tr.firstChild.className !== "diff-marker" ) {
					// We're dealing with a heading line
					var
						/**
						 * Above each change line, there's a bolded line indicating what part of the
						 * item is being changed. Examples: "description / en", "links/enwiki/name",
						 * "Property: instance of: city / qualifiers". This line can contain links.
						 * @type {HTMLTableCellElement}
						 */
						headerNode = tr.firstChild.firstChild ? tr.firstChild : tr.lastChild,
						propData = headerNode.textContent.split( " / " ),
						//mainType = propData[ 0 ],
						mainType = msgArray.indexOf( propData[ 0 ] ),

						/**
						 * The actual diff line, showing the added/removed elements.
						 */
						trValueBlock = tr.nextSibling,
						valueTds = {
							removed: trValueBlock.firstChild.className === "diff-marker" && trValueBlock.childNodes[ 1 ],
							added: trValueBlock.lastChild.className === "diff-addedline" && trValueBlock.lastChild
						};

					switch ( mainType ) {
						case PROPERTY:
							// Bunch of different situations here:
							// If a brand new statement, highlight all.
							// If removing full statement, highlight and strike all.
							// If changing a statement, don't highlight prop, highlight values.
							// If just adding/removing/changing qualifiers/sources, don't highlight values either.
							// Mixing these has complex results.
							// Note: Depending on data type, value might not be a link.

							// Maybe build the skeleton in advance?

							// Overall structure: diffComponents = [ TODO ]


							/**
							 * The format of the headers for statements is
							 * "Property / " < link to property >
							 * 		[ ": " < value > " / " < sub-component > ]
							 */


							var
								/**
								 * A link to the statement's property.
								 */
								propLink = headerNode.firstChild.nextSibling,
								/** The property's name/ID. (Eg, "Property:P123".) */
								prop = propLink.title,
								/** @type {StatementChange} */
								propChangeGroup = 
									( diffComponents[ mainType ][ prop ] ||= { values: {}, link: propLink } );
							if ( !propLink ) {
								//console.log( "no proplink", node );
							}
							$.each( valueTds, function ( removedOrAdded, td ) {
								var valNode,
									/** @type {StatementChangeValue} */
									valGroup;
								if ( !td || !td.firstChild?.firstChild ) {
									//console.log( "break", td );
									return;
								}
								$( [ headerNode, td ] ).find( ".wb-language-fallback-indicator" ).remove();
								// Assuming that only two nodes means it's a simple statement.
								if ( headerNode.childNodes.length === 2 ) {
									// Simple statement
									var
										val = td.firstChild.firstChild.firstChild,
										/** @type {string} */
										valName;
									// What's the datatype?
									// Different datatypes have very different output formats
									// to be parsed.
									if ( val.firstChild?.nodeName === "A" ) {
										// We have a straight link. Probably item datatype.
										valNode = val.firstChild;
										// Images and whatnot don't have titles.
										valName = valNode.title || valNode.textContent;
									} else if ( val.firstChild && val.lastChild.nodeName === "TABLE" ) { 
										// Table filled with stuff, probably quantity or date.
										// (Date uses <b /><table />, quantity uses <h4 /><table />)
										valName = val.firstChild.textContent;
										valNode = document.createElement( "span" );
										valNode.append( valName );
									} else {
										// Straight string, eg string datatype.
										valName = val.textContent;
										valNode = val;
									}
									valGroup = 
										propChangeGroup.values[ valName ] ||= {};
									valGroup.change = removedOrAdded;
								} else {
									// Qualifier, Source, or Rank statement
									// The value is sometimes a string, sometimes a link.
									var subtype = propData[ propData.length - 1 ],
										hasValueLink = headerNode.childNodes[ 3 ] && headerNode.childNodes[ 3 ].nodeName === "A",
										valName = hasValueLink ?
											headerNode.childNodes[ 3 ].title || headerNode.childNodes[ 3 ].textContent :
											propData[ 1 ].split( ": " )[ 1 ];
									
									valGroup = 
										propChangeGroup.values[ valName ] ||= {};

									valNode = hasValueLink ? 
										headerNode.childNodes[ 3 ] : 
										document.createTextNode( valName );

									/**
									 * Get the value of the qualifier/reference part.
									 * There are 3 options: ": <a>content</a>", ": <h4>content</h4><table />", and ": content"
									 * Only copy the element itself if it's a link.
									 * Occasionally there's actually a span element indicating an error.
									 * If so, try for the previous element.
									 * (Apologies for the terrible function name.)
									 * @param {Element} t 
									 * @returns {Element | Text}
									 */
									function getStuff( t ) {
										return t.nodeName === "A" ? 
											t : 
											document.createTextNode( 
												( t.nodeName === "TABLE" || t.nodeName === "SPAN" ) ? 
													t.previousElementSibling.textContent : 
													// This breaks sometimes. TODO.
													t.nodeValue.slice( 2 )
													//t.nodeValue.split( ":" )[ 1 ]
											);
									}

									// TODO: Try to merge these, if possible.
									switch ( subtype ) {
										case msgs.reference: {
											// A statement can have any number of references.
											// References have a list of statements, but are one block each.
											let subGroup = valGroup.references ||= [],
												ref = [];
											td && subGroup.push( ref );
											ref.change = removedOrAdded;
											// Series of spans, separated by brs.
											$( ">div>.diffchange>span", td ).each( function () {
												ref.push( {
													propLink: this.firstChild,
													value: getStuff( this.lastChild )
												} );
											} );

											break;
										}
										case msgs.qualifier: {
											// Should this be an array or object containing arrays? There can be multiple
											// qualifiers of the same type, +added/removed values, ...
											// Still, changed values need to be shown...
											let subGroup = valGroup.qualifiers ||= {},
												qualSpan = td.firstChild.firstChild.firstChild,
												// The value will always start with a link to the property.
												subPropLink = qualSpan.firstChild,
												subPropName = subPropLink.title,
												qualList = subGroup[ subPropName ] ||= [],
												qual = {};
											qualList.push( qual );
											qualList.link = subPropLink;
											qual.change = removedOrAdded;
											qual.value = getStuff( qualSpan.lastChild );

											// Clear duplicates
											qualList.forEach( function ( a, i ) {
												if ( ( a.value.title || a.value.nodeValue ) === ( qual.value.title || qual.value.nodeValue ) && a.change !== qual.change ) {
													qualList.splice( qualList.length - 1, 1 );
													qualList.splice( i, 1 );
													if ( qualList.length === 0 ) {
														delete subGroup[ subPropName ];
													}
													return false;
												}
											} );
											// After, there's a ": ", but I don't know what happens to strings.
											break;
										}
										case msgs.rank:
											var subGroup = valGroup.rank ||= {},
												rankSpan = td.firstChild.firstChild.firstChild,
												rankName = rankSpan.textContent,
												rank = ( [ 
													msgs[ "wikibase-diffview-rank-preferred" ],
													msgs[ "wikibase-diffview-rank-normal" ],
													msgs[ "wikibase-diffview-rank-deprecated" ]
												] ).indexOf( rankName );
											
											subGroup[ removedOrAdded ] = { rank, rankName };
											break;
									}
								}
								valGroup.node ||= valNode;
							} );
							break;
						case LABEL:
						case DESCRIPTION:
						case LINK:
						case ALIAS:
							var langOrWiki = propData[ 1 ],
								isAlias = mainType === ALIAS,
								newBlock =
									diffComponents[ mainType ][ langOrWiki ] ||= ( isAlias ? [] : {} ),
								isBadge = propData[ 2 ] === msgs[ 'wikibase-diffview-link-badges' ]; // "badges"
							
							if ( valueTds.removed && valueTds.added ) {
								newBlock.modified = mergeChangesDom(
									valueTds.removed, valueTds.added
								);
							} else {

								$.each( valueTds, function ( addedOrRemoved, td ) {
									if ( !td.firstChild?.firstChild ) {
										//console.log( 777, td, diffLink.parentNode, $x );
									}
									if ( td ) {
										if ( !isBadge ) {
											// Normal label, description, alias, or sitelink
											if ( td ) {
												if ( isAlias ) {
													newBlock.push( newBlock = {} );
												}
												newBlock[ addedOrRemoved ] = td.firstChild.firstChild;
											}
										} else {
											// Badge.
											newBlock.badges ||= [];
											var newBadge = {
												[ addedOrRemoved ]: td.firstChild.firstChild.firstChild
											};
											newBlock.badges.push( newBadge );
										}
									}
								} );
							}
							break;
					}
				}
			} );

			return diffComponents;
		}

		/**
		 * Take an individual listed diff, grab its data, and enhance the
		 * listed diff.
		 * @param {Element} diffLinkParent 
		 * @param {string} oldid 
		 * @param {string} pageid 
		 * @param {string} diffid 
		 * @param {string} title 
		 */
		function processDiff( diffLinkParent, oldid, pageid, diffid, title ) {
			
			api.get( {
				action: "query",
				prop: "revisions",
				//rvprop: "timestamp|user|comment",
				//pageids: pageid,
				titles: title,
				rvstartid: oldid,
				//rvdiffto: "next",
				rvdiffto: diffid,
				rvlimit: 1,
				uselang: lang, 
				maxage:  60 * 60 * 24 * 3,
				smaxage: 60 * 60 * 24 * 3
			} ).done( function ( result ) {
				// if ( !result || !result.query || !result.query.pages || result.query.pages[ -1 ] ) {
				if ( !result?.query?.pages || result.query.pages[ -1 ] ) {
					console.error( 'DiffLists: Diff not found.', result );
					return;
				}
				
				// TODO: getStuff stuff.
				// TODO: Grab localizations for everything.
				// 
				
				var revision = Object.values( result.query.pages )[ 0 ].revisions[ 0 ],
					/** @type {string} */
					timestamp = revision.timestamp,
					/** @type {string} */
					diffHtml = revision.diff[ "*" ],
					
					// enhanced = diffLinkParent.nodeName === "TD",
					
					nested = groupChangesUserPreference && diffLinkParent.className === "mw-enhanced-rc-nested",
					
					// The element that holds the whole edit listing, for hiding purposes. Three different situations:
					// Regular watchlist, enhanced watchlist, and enhanced nested.
					// Enhanced lists are lists of tables, the first row of each being main, others nested.
					outer = diffLinkParent.closest( 'tr, li' ),


					/**
					 * @type {DiffComponents}
					 */
					diffComponents = parseDiff( diffHtml ),


					/**
					 * Array of DOM to be added
					 * @type {Chunk[]}
					 */
					chunks = buildChunks( diffComponents ),
					
                    /** @type {Diff} */
					diff = { elem: outer, chunks, components: diffComponents };
					
				allDiffs.push( diff );
				
				if ( chunks.length ) {
					// Add to the visible DOM.
					// addChunks( chunks, diffLinkParent );

					if ( !outer ) {
						console.log('oop', diff, outer, diffLinkParent );
					}


					addChunks( chunks, outer );
					
					// Hide whatever needs to be hidden, per the user's preferences/settings.
					updateDisplay( [ diff ] );

					outer.classList.add( 'YR-cl-enhanceddiff' );



					// For those who have checked "Group changes by page in recent changes and watchlist"
					// in preferences, there are nested listings. We reconstruct the parent
					// by combining the contents of each of the individual listings.
					if ( nested ) {

						var
							// Clone the individual nodes in our generated summary, for the parent diff.
							dupChunks = ( chunks.map( x => ( { ...x, elem: x.elem.cloneNode( true ) } ) ) ),

							toAdd = { timestamp, firstElem: dupChunks[ 0 ].elem },

							tbody = outer.parentNode,

							// These lines are the Map way of saying nesteds = nestedParents[ tbody ] ||= { ... };
							nesteds = nestedParents.get( tbody ),
							
							/** Index of element to insert the new chunks immediately before. */
							insertionIndex = 0;
						
						if ( !nesteds ) {
							
							nesteds = {
								diff: { chunks: [], components: [ {}, {}, {}, {}, {} ], elem: tbody },
								markers: []
							};
							nestedParents.set( tbody, nesteds );
							allDiffs.push( nesteds.diff );
						}
						
						for ( ; insertionIndex < nesteds.markers.length; insertionIndex++ ) {
							if ( timestamp < nesteds.markers[ insertionIndex ].timestamp ) {
								// insertionIndex = insertionIndex;
								break;
							}
						}

						var insertionElement = nesteds.markers[ insertionIndex ]?.firstElem.parentNode;
						
						// addChunks( dupChunks, tbody.firstElementChild.lastElementChild, nesteds, toAdd );
						addChunks( dupChunks, tbody.firstElementChild.lastElementChild, {
							insertBefore: insertionElement,
							markers: nesteds.markers
						} );

						nesteds.markers.splice( insertionIndex, 0, toAdd );
						
						nesteds.diff.chunks.push( ...dupChunks );
						nesteds.diff.components.forEach( ( component, i ) => $.extend( component, diffComponents[ i ] ) );
						
						
						updateDisplay( [ nesteds.diff ] );
						
					}
					
				}
				// If the diff didn't come up with anything, leave it alone.

			} );
		}
		
		function enhanceDiffLinks() {
			// console.log(3, $( changeSelector ) );

			// Run through every diff link.
			$( changeSelector ).each( function( i, diffLink ) {
				// The diff API will only give one at a time...
				var href = diffLink.href,
					oldid = mw.util.getParamValue( "oldid", href ), //result.old_revid,
					pageid = mw.util.getParamValue( "curid", href ),
					diffid = mw.util.getParamValue( "diff", href ),
					title = mw.util.getParamValue( "title", href ),
					// This should be the immediate parent of the summary.
					diffLinkParent = listType === 'normal' ?
						groupChangesUserPreference ?
							diffLink.parentNode.nodeName === 'TD' ?
								diffLink.parentNode : // Is nested
								diffLink.parentNode // Isn't nested
								:
							diffLink.parentNode.parentNode :
						// Contribs, history
						diffLink.parentNode.parentNode.parentNode;
					// enhanced = diffLinkParent.nodeName === "TD";
				// console.log( 'check', diffLinkParent, diffLink, title, pageid, diffid, oldid, listType, enhanced );
				if ( 
					// Only well-formed diff links
					diffid && title && oldid && 
					// On history pages, don't show earliest diff.
					// Don't show earliest diff on page, even if there's a diff
					// link, because rvstartid doesn't play nice with "prev". 
					// TODO: Fix.
					( listType !== "history" || diffLinkParent.parentNode.nextElementSibling /* || diffLink.previousSibling.previousSibling */ ) &&
					// Check namespace, only show mainspace and Property: NS.
					( !title.includes( ":" ) || title.startsWith( "Property:" ) || !isWD ) &&
					// Suppress diffs with nested sub-listings.
					// No longer necessary? Groups now use ".mw-changeslist-groupdiff" class.
					//( !enhanced || !diffLinkParent.parentNode.nextElementSibling || diffLinkParent.parentNode.previousElementSibling )
					// Don't process any diff twice
					!diffLink.YR_DL_processed
				) {
					diffLink.YR_DL_processed = true;

					// On non-WD wikis, entity links are prefixed with this special page.
					// I don't really know why, but let's trim it so the API is okay.
					if ( !isWD && title.startsWith( 'Special:EntityPage/' ) ) {
						title = title.slice( 'Special:EntityPage/'.length )
					}

					processDiff( diffLinkParent, oldid, pageid, diffid, title );
				}
			} );
		}
		
		// buildOptionsBox();
		var updateDisplay = prepareHidingPreferences();
		
		mw.hook( 'wikipage.content' ).add( enhanceDiffLinks );
		
	});
});

}


mw.loader.using( mw.config.get( 'wgDBname' ) === 'wikidatawiki' ? 'mediawiki.api' : 'mediawiki.ForeignApi', DiffLists );

		/*
		$.get( api, { 
			format: "json", 
			action: "query",
			list: "recentchanges",
			rcnamespace: 0,
			rcprop: "ids"
		}, function ( r ) {
			//console.log( r );
			//var results = r.query.recentchanges; // array
			results.forEach( function ( result ) {
				var oldid = result.old_revid,
					pageid = result.pageid;
		*/