Note: After saving, changes may not occur immediately. Click here to learn how to bypass your browser's cache.
  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Cmd-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (Cmd-Shift-R on a Mac)
  • Internet Explorer: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Clear the cache in Tools → Preferences

For details and instructions about other browsers, see Wikipedia:Bypass your cache.

/**
 * Re-imagining of the Navigation popups gadgets.
 * Principally, a simple way to append hooks for actions and content is
 * hoped for so it's easy to "plug in" extra functions.
 *
 * It also listens for page mutations and adds handlers to links added by
 * JS, which can be helpful.
 *
 * There are three types of "hooks" you can register:
 * * recogniser hook: work out the "canonical" form of a link
 * * content hook: displays some content relating to a link
 * * action hooks: adds to the actions related to a link
 */

// IE11 users have to git gud
/* eslint-disable no-var, compat/compat */

( function ( $, mw, Promise ) {

	'use strict';

	var Popups = {
		cfg: {
			recogniserHooks: [],
			contentHooks: [],
			actionHooks: [],
			skinDenylist: [ 'minerva' ],
			userContribsByDefault: true,
			showTimeout: 500,
			imgWidth: 400,
			rcTypes: [ 'edit', 'new', 'log' ],
			watchlistTypes: [ 'edit', 'new', 'log' ], // can add categorize
			// how many log events (RC, watchlist, contribs..) to show
			logLimit: {
				default: 25
			},
			// how many links to show
			linkLimit: {
				default: 25
			},
			// namespaces to show shortcut links for in contribs actions
			nsLinkList: {
				default: [ 0, 1, 2, 3, 4, 5, 8, 10, 12, 14, 100, 102, 103, 104, 106, 114, 828 ]
			},
			timeOffset: 0, // TODO
			icons: {
				copy: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Ic_content_copy_48px.svg/16px-Ic_content_copy_48px.svg.png',
				edit: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Ic_create_48px.svg/16px-Ic_create_48px.svg.png'
			},
			// Provide these special block types
			blocks: [
				{
					asAction: true,
					name: 'spam',
					expiry: 'indefinite',
					message: 'popups-block-spam-reason',
					allowusertalk: true,
					watchuser: true
				}
			],
			// provide these special delete types
			deletes: [
				{
					asAction: true,
					name: 'spam',
					message: 'popups-delete-spam-reason',
					watchlist: 'watch'
				}
			]
		},
		userRights: [],
		// cache of siteinfo
		siteinfo: []
	};

	// Set default messages - users/wikis can override
	mw.messages.set( {
		'popups-block-confirm': 'Are you sure you want to block $1 with reason "$2"?',
		'popups-user-blocked': '$1 blocked successfully',
		'popups-user-block-failed': 'Failed to block $1',
		'popups-block-spam-reason': 'Spamming/Promoting links to external sites',

		'popups-delete-ok': '$1 deleted successfully',
		'popups-delete-failed': 'Failed to delete $1',
		'popups-delete-confirm': 'Are you sure you want to delete $1 with reason "$2"?',
		'popups-delete-spam-reason': 'Spamming/Promoting links to external sites',

		'popups-actions-delete': 'del',
		'popups-action-mass-delete': 'nuke',

		'popups-actions-move': 'move',
		'popups-actions-info': 'info',

		'popups-diff-template': '{{diff|$1}}'
	} );

	// Wikis/users can use this hook to set their own messages
	mw.hook( 'ext.gadget.popups-reloaded.messages' ).fire();

	// returns promise with boolean "confirmed"
	function confirmAction( message ) {
		return OO.ui.confirm( message )
			.then( function ( confirmed ) {
				if ( !confirmed ) {
					return Promise.reject( confirmed );
				}

				return confirmed;
			} );
	}

	Popups.wiki = {
		server: mw.config.get( 'wgServer' ),
		serverName: mw.config.get( 'wgServerName' ),
		indexLink: mw.config.get( 'wgServer' ) + mw.config.get( 'wgScript' )
	};

	Popups.commons = {
		server: '//commons.wikimedia.org',
		serverName: 'commons.wikimedia.org',
		indexLink: '//commons.wikimedia.org/w/index.php',
		apiLink: '//commons.wikimedia.org/w/api.php'
	};

	Popups.wikidata = {
		server: '//www.wikidata.org',
		serverName: 'www.wikidata.org',
		indexLink: '//www.wikidata.org/w/index.php'
	};

	/* these wikis can be used in ForeignApi */
	Popups.foreignWikis = [
		Popups.commons.serverName,
		Popups.wikidata.serverName,
		'wikipedia.org',
		'wikisource.org',
		'wikibooks.org',
		'wikispecies.org',
		'wikiquote.org',
		'wiktionary.org',
		'wikivoyage.org',
		'wikiversity.org',
		'wikinews.org',
		'mediawiki.org',
		'meta.wikimedia.org',
		'foundation.wikimedia.org'
	];

	function haveRight( right ) {
		return Popups.userRights.indexOf( right ) !== -1;
	}

	function recognisedWikiServer( serverName ) {
		// first, check the current wiki
		if ( serverName === Popups.wiki.serverName ) {
			return true;
		}

		for ( var i = 0; i < Popups.foreignWikis.length; ++i ) {
			if ( serverName.endsWith( Popups.foreignWikis[ i ] ) ) {
				return true;
			}
		}

		return false;
	}

	function getWikiArticleUrl( serverName, title ) {
		return '//' + serverName + '/wiki/' + title;
	}

	function getWikiActionUrl( serverName, title, action, params ) {
		var url = '//' + serverName + '/w/index.php?title=' + encodeURIComponent( title );
		if ( action ) {
			url += '&action=' + action;
		}

		if ( params ) {
			Object.keys( params ).forEach( function ( key ) {
				url += '&' + key + '=' + params[ key ];
			} );
		}
		return url;
	}

	function getArticleUrl( article, encode ) {
		var enc = encode ? encodeURIComponent : function ( x ) {
			return x;
		};
		return mw.config.get( 'wgArticlePath' ).replace( '$1', enc( article ) );
	}

	function getActionUrl( pg, action, params ) {
		return getWikiActionUrl( Popups.wiki.serverName, pg, action, params );
	}

	function getHrefLink( href, text ) {
		text = text || href;
		return '<a href="' + href + '">' + text + '</a>';
	}

	function getPageLink( serverName, title, text ) {
		text = text || title;
		return '<a href="' + getWikiArticleUrl( serverName, title, true ) + '">' + text + '</a>';
	}

	function getActionLink( serverName, pg, action, text ) {
		return getHrefLink( getWikiActionUrl( serverName, pg, action ), text );
	}

	function getRevidLink( serverName, title, rev, text ) {
		var href = getWikiActionUrl( serverName, title, null,
			{ oldid: rev.revid } );
		return getHrefLink( href, text );
	}

	function getDiffUrl( serverName, title, ida, idb ) {
		return getWikiActionUrl( serverName, title, null,
			{ diff: idb, oldid: ida } );
	}

	function getDiffLink( serverName, title, ida, idb, text ) {
		return getHrefLink( getDiffUrl( serverName, title, ida, idb ), text );
	}

	function getUserLink( serverName, user ) {
		user = user.replace( /^[uU]ser:/, '' );
		return getPageLink( serverName, 'User:' + user, user );
	}

	function getCommonsArticleUrl( filename ) {
		return Popups.commons.server + '/wiki/' + filename;
	}

	function getCommonsActionUrl( filename, action ) {
		return Popups.commons.indexLink + '?title=' +
				filename + '&action=' + action;
	}

	function getCommonsOrLocalIndexPhp( local ) {
		return local ? Popups.wiki.indexLink : Popups.commons.indexLink;
	}

	function getReuploadUrl( filename, local ) {
		return getCommonsOrLocalIndexPhp( local ) + '?title=Special:Upload' +
				'&wpDestFile=' + filename + '&wpForReUpload=1';
	}

	function isNamespaceOrTalk( candidate, wanted ) {
		return candidate === wanted || ( candidate === wanted + ' talk' );
	}

	var getSpecialDiffLink = function ( revid ) {
		// eslint-disable-next-line no-useless-concat
		return '[' + '[Special:Diff/' + revid.toString() + '|' + revid.toString() + ']]';
	};

	var getDatetimeFromTimestamp = function ( ts ) {
		return ts.replace( 'T', ' ' ).replace( 'Z', '' ); // 2018-12-18T16:59:42Z"
	};

	function bracketedLinkList( links ) {
		var o = '<span>(' + links[ 0 ];

		for ( var i = 1; i < links.length; i++ ) {
			o += '&nbsp;|&nbsp;' + links[ i ];
		}
		o += ')</span>';
		return o;
	}

	function repeatString( s, mult ) {
		var ret = '';
		for ( var i = 0; i < mult; ++i ) {
			ret += s;
		}
		return ret;
	}

	function zeroFill( s, min ) {
		min = min || 2;
		var t = s.toString();
		return repeatString( '0', min - t.length ) + t;
	}

	function mapArray( f, o ) {
		var ret = [];
		for ( var i = 0; i < o.length; ++i ) {
			ret.push( f( o[ i ] ) );
		}
		return ret;
	}

	function mapObject( f, o ) {
		var ret = {};
		for ( var i in o ) {
			ret[ o ] = f( o[ i ] );
		}
		return ret;
	}

	function map( f, o ) {
		if ( Array.isArray( o ) ) {
			return mapArray( f, o );
		}
		return mapObject( f, o );
	}

	function getDateFromTimestamp( t ) {
		var s = t.split( /[^0-9]/ );
		switch ( s.length ) {
			case 0: return null;
			case 1: return new Date( s[ 0 ] );
			case 2: return new Date( s[ 0 ], s[ 1 ] - 1 );
			case 3: return new Date( s[ 0 ], s[ 1 ] - 1, s[ 2 ] );
			case 4: return new Date( s[ 0 ], s[ 1 ] - 1, s[ 2 ], s[ 3 ] );
			case 5: return new Date( s[ 0 ], s[ 1 ] - 1, s[ 2 ], s[ 3 ], s[ 4 ] );
			case 6: return new Date( s[ 0 ], s[ 1 ] - 1, s[ 2 ], s[ 3 ], s[ 4 ], s[ 5 ] );
			default: return new Date( s[ 0 ], s[ 1 ] - 1, s[ 2 ], s[ 3 ], s[ 4 ], s[ 5 ], s[ 6 ] );
		}
	}

	function adjustDate( d, offset ) {
		// offset is in minutes
		var o = offset * 60 * 1000;
		return new Date( +d + o );
	}

	function dayFormat( editDate, utc ) {
		if ( utc ) {
			return map( zeroFill, [ editDate.getUTCFullYear(), editDate.getUTCMonth() + 1, editDate.getUTCDate() ] ).join( '-' );
		}
		return map( zeroFill, [ editDate.getFullYear(), editDate.getMonth() + 1, editDate.getDate() ] ).join( '-' );
	}

	function timeFormat( editDate, utc ) {
		if ( utc ) {
			return map( zeroFill, [ editDate.getUTCHours(), editDate.getUTCMinutes(), editDate.getUTCSeconds() ] ).join( ':' );
		}
		return map( zeroFill, [ editDate.getHours(), editDate.getMinutes(), editDate.getSeconds() ] ).join( ':' );
	}

	function makeClassedSpan( cls, text ) {
		// eslint-disable-next-line mediawiki/class-doc
		return $( '<span>' )
			.addClass( cls )
			.append( text );
	}

	function makeColspanTableRow( classes, colspan, content ) {
		return '<tr><td colspan=' + colspan + " class='" + classes + "'>" +
						content + '<td></tr>';
	}

	function makeOpenTag( tag, cls ) {
		if ( cls ) {
			return '<' + tag + " class='" + cls + "'>";
		} else {
			return '<' + tag + '>';
		}
	}

	function makeEnclosingTag( tag, text, cls ) {
		var o = '<' + tag;
		if ( cls ) {
			o += " class='" + cls + "'";
		}
		o += '>' + ( text || '' ) + '</' + tag + '>';
		return o;
	}

	function arraysIntersect( a, b ) {
		return a.some( function ( v ) {
			return b.indexOf( v ) !== -1;
		} );
	}

	function Api( serverName ) {
		this.serverName = serverName;

		if ( serverName === mw.config.get( 'wgServerName' ) ) {
			this.api = new mw.Api();
		} else {
			this.api = new mw.ForeignApi( '//' + serverName + '/w/api.php' );
		}
	}

	Api.prototype.defaultFormat = function ( params ) {
		if ( !params.format ) {
			params.format = 'json';
			params.formatversion = 2;
		}
		return params;
	};

	Api.prototype.get = function ( params, commonsFallback ) {
		params = this.defaultFormat( params );

		return this.api.get( params )
			.then( function ( data ) {
				return data;
			},
			function ( data ) {
				if ( commonsFallback && data === 'missingtitle' ) {
					return new mw.ForeignApi( Popups.commons.apiLink )
						.get( params );
				}
				throw ( data );
			} );
	};

	Api.prototype.post = function ( params ) {
		params = this.defaultFormat( params );
		return this.api.post( params );
	};

	Api.prototype.postWithToken = function ( token, params ) {
		params = this.defaultFormat( params );
		return this.api.postWithToken( token, params );
	};

	Api.prototype.getSiteInfo = function ( siProps ) {
		const params = {
			action: 'query',
			meta: 'siteinfo',
			siprop: siProps.join( '|' )
		};

		return this.api.get( params )
			.then( ( data ) => data.query );
	};

	Api.prototype.getSections = function ( pageTitle ) {
		const params = {
			action: 'parse',
			page: pageTitle,
			prop: 'sections'
		};

		return this.api.get( params )
			.then( ( data ) => data.parse.sections );
	};

	Api.prototype.getSectionWithTitle = function ( pageTitle, secTitle ) {

		return this.getSections( pageTitle )
			.then( ( sections ) => {

				for ( const section of sections ) {
					if ( section.line === secTitle || section.anchor === secTitle ) {
						return section;
					}
				}

				throw `No section ${secTitle} at ${pageTitle}`;
			} );
	};

	Api.prototype.getPageRender = function ( title, section ) {
		const params = {
			action: 'parse',
			page: title,
			section: section
		};

		return this.get( params )
			.then( function ( data ) {
				return data.parse;
			} );
	};

	Api.prototype.getPageImageUrl = function ( filename, page, size ) {
		var params = {
			action: 'query',
			titles: 'File:' + filename,
			prop: 'imageinfo',
			iiprop: 'url'
		};

		if ( size && page ) {
			params.iiurlparam = 'page' + page + '-' + size + 'px';
		}

		return this.get( params );
	};

	Api.prototype.getPageThumbUrlForTitle = function ( titleNoNs, size ) {
		// load page image
		var fn = titleNoNs.replace( /\/\d+$/, '' );
		var pg = titleNoNs.replace( /^.*\/(\d+)$/, '$1' );

		return this.getPageImageUrl( fn, pg, size )
			.then( function ( data ) {
				return data.query.pages[ 0 ].imageinfo[ 0 ].thumburl;
			} );
	};

	Api.prototype.getPageWikitext = function ( title, oldid ) {
		var slot = 'main';
		var params = {
			action: 'query',
			prop: 'revisions',
			rvslots: slot,
			rvprop: 'content',
			titles: title,
			rvlimit: 1
		};

		if ( oldid ) {
			params.rvstartid = oldid;
			params.rvendid = oldid;
		}

		return this.get( params )
			.then( function ( data ) {
				return data.query.pages[ 0 ].revisions[ 0 ].slots[ slot ].content;
			} );
	};

	/* resolves with true or false */
	Api.prototype.ifPageExists = function ( title ) {
		var params = {
			action: 'query',
			titles: title
		};
		return this.get( params )
			.then( function ( data ) {
				return !data.query.pages[ 0 ].missing;
			} );
	};

	Api.prototype.getOldRevision = function ( oldid ) {
		var params = {
			action: 'parse',
			oldid: oldid,
			prop: 'text'
		};

		return this.get( params )
			.then( function ( data ) {
				var $content = $( '<div>' )
					.addClass( 'popups_page_content popups_oldrev_content' );

				var oldcontent = data.parse.text;
				$content.append( oldcontent );
				return $content;
			} );
	};

	Api.prototype.getPageHistory = function ( title, limit, rvprops ) {
		var params = {
			action: 'query',
			titles: title,
			prop: 'revisions',
			rvprop: rvprops.join( '|' ),
			rvlimit: limit
		};
		return this.get( params )
			.then( function ( data ) {
				return data.query.pages[ 0 ];
			} );
	};

	Api.prototype.getCurrentRevs = function ( titles ) {
		var params = {
			action: 'query',
			prop: 'revisions',
			titles: Array.isArray( titles ) ? titles.join( '|' ) : titles
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.pages.map( function ( p ) {
					return p.revisions[ 0 ];
				} );
			} );
	};

	/*
	 * Get what we need to undo a single edit
	 *
	 * Returns a promise that resolves with the 'compare' data.
	 */
	Api.prototype.getSingleEditCompare = function ( revision, props ) {

		var params = {
			action: 'compare',
			fromrev: revision,
			torelative: 'prev',
			prop: props.join( '|' )
		};

		return this.postWithToken( 'csrf', params )
			.then( function ( data ) {
				return data.compare;
			} );
	};

	Api.prototype.rollbackEdit = function ( toId, toUser ) {
		var params = {
			action: 'rollback',
			pageid: toId,
			user: toUser
		};

		return this.postWithToken( 'rollback', params );
	};

	Api.prototype.patrolRevision = function ( revid, tags ) {
		const params = {
			action: 'patrol',
			revid: revid
		};

		if ( tags ) {
			params.tags = tags.join( '|' );
		}

		return this.postWithToken( 'patrol', params );
	};

	Api.prototype.thankForRevision = function ( revision ) {
		var params = {
			action: 'thank',
			rev: revision
		};
		return this
			.postWithToken( 'csrf', params );
	};

	Api.prototype.getWhatLeavesHere = function ( title, props, limit ) {
		var params = {
			action: 'query',
			titles: title,
			prop: props.join( '|' ),
			tllimit: limit,
			imlimit: limit,
			pllimit: limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.pages[ 0 ];
			} );
	};

	Api.prototype.getImageInfo = function ( image, props ) {
		var params = {
			action: 'parse',
			page: image,
			prop: props.join( '|' )
		};

		return this.get( params, true )
			.then( function ( data ) {
				return data.parse.text;
			} );
	};

	Api.prototype.getRecentChanges = function ( title, options ) {
		const params = {
			action: 'query',
			list: 'recentchanges',
			rctitle: title,
			rcprop: options.props ? options.props.join( '|' ) : undefined,
			rctype: options.types ? options.types.join( '|' ) : undefined,
			rcnamespace: options.ns ? options.ns.join( '|' ) : undefined,
			rclimit: options.limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.recentchanges;
			} );
	};

	Api.prototype.getWatchlistEntries = function ( excudeUsers, wlProps, wlTypes,
		wlNamespaces, limit ) {
		const params = {
			action: 'query',
			list: 'watchlist',
			wlexcludeuser: excudeUsers,
			wlprop: wlProps.join( '|' ),
			wltype: wlTypes.join( '|' ),
			wllimit: limit,
			wlallrev: 1
		};

		if ( wlNamespaces && wlNamespaces.length ) {
			params.wlnamespace = wlNamespaces.join( '|' );
		}

		return this.get( params )
			.then( function ( data ) {
				return data.query.watchlist;
			} );
	};

	Api.prototype.getCategoryMembers = function ( category, limit ) {
		var params = {
			action: 'query',
			list: 'categorymembers',
			cmtitle: category,
			cmlimit: limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.categorymembers;
			} );
	};

	Api.prototype.getRandomPages = function ( namespaces, limit ) {
		var params = {
			action: 'query',
			list: 'random',
			rnlimit: limit
		};
		if ( namespaces ) {
			params.rnnamespace = namespaces.join( '|' );
		}

		return this.get( params )
			.then( function ( data ) {
				return data.query.random;
			} );
	};

	Api.prototype.getPageInfo = function ( title, inprops ) {

		var params = {
			action: 'query',
			titles: title,
			prop: 'info',
			inprop: inprops.join( '|' )
		};

		var ok = function ( data ) {
			return data.query.pages[ 0 ];
		};

		var fail = function () {
			console.error( 'GET Failed: for pageinfo: ', title );
		};

		return this.get( params )
			.then( ok, fail );
	};

	Api.prototype.getUserContributions = function ( user, ucprops, namespace, limit ) {

		var params = {
			action: 'query',
			list: 'usercontribs',
			ucprop: ucprops.join( '|' ),
			ucuser: user,
			uclimit: limit,
			ucnamespace: namespace
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.usercontribs;
			},
			function () {
				console.error( 'GET Failed: for usercontribs: ', user );
			} );
	};

	Api.prototype.getUserLog = function ( user, leprops, limit ) {

		var params = {
			action: 'query',
			list: 'logevents',
			leuser: user,
			leprop: leprops.join( '|' ),
			lelimit: limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.logevents;
			} );
	};

	// Type: all, new, overwrite, revert
	Api.prototype.getUserFiles = function ( user, type, limit ) {
		const params = {
			action: 'query',
			list: 'logevents',
			leuser: user,
			lelimit: limit
		};

		switch ( type ) {
			case 'all':
				params.letype = 'upload';
				break;
			case 'new':
				params.leaction = 'upload/upload';
				break;
			case 'overwrite':
				params.leaction = 'upload/overwrite';
				break;
			case 'revert':
				params.leaction = 'upload/revert';
				break;
		}

		return this.get( params )
			.then( function ( data ) {
				return data.query.logevents;
			} );
	};

	Api.prototype.getUserAbuseFilterLog = function ( user, aflprops, limit ) {

		var params = {
			action: 'query',
			list: 'abuselog',
			afluser: user,
			aflprop: aflprops.join( '|' ),
			afllimit: limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query.abuselog;
			} );
	};

	Api.prototype.getAbuseFilterLogEntry = function ( aflentry ) {
		const params = {
			action: 'query',
			list: 'abuselog',
			afllogid: aflentry,
			aflprop: 'ids|details|filter'
		};

		return this.get( params )
			.then( ( data ) => {
				return data.query.abuselog[ 0 ];
			} );
	};

	Api.prototype.getWhatLinksHere = function ( title, types, limit ) {
		var params = {
			action: 'query',
			list: types.join( '|' ),
			bltitle: title,
			bllimit: limit,
			eititle: title,
			eilimit: limit
		};

		return this.get( params )
			.then( function ( data ) {
				return data.query;
			} );
	};

	Api.prototype.doPurgePage = function ( page ) {
		var params = {
			action: 'purge',
			titles: page,
			forcerecursivelinkupdate: 1,
			redirects: 1
		};

		return this.post( params );
	};

	/* Returns a promise that resolves with image info */
	Api.prototype.getFileImageinfo = function ( filename, iiprops ) {
		var params = {
			action: 'query',
			formatversion: 2,
			titles: 'File:' + filename,
			prop: 'imageinfo'
		};

		if ( iiprops && iiprops.length ) {
			params.iiprop = iiprops.join( '|' );
		}

		return this.get( params )
			.then( function ( data ) {
				return data.query.pages[ 0 ];
			} );
	};

	Api.prototype.getFileIsLocal = function ( filename ) {
		return this
			.getFileImageinfo( filename, null )
			.then( function ( imageinfo ) {
				return imageinfo.imagerepository !== 'shared';
			} );
	};

	Api.prototype.watchPage = function ( title, watch ) {
		var params = {
			action: 'watch',
			titles: title
		};
		if ( !watch ) {
			params.unwatch = 1;
		}

		return this.postWithToken( 'watch', params );
	};

	Api.prototype.blockUser = function ( user, expiry, options ) {
		var params = {
			action: 'block',
			expiry: expiry,
			user: user,
			reason: options.reason,
			watchuser: options.watchuser,
			allowusertalk: options.allowusertalk
		};

		return this.postWithToken( 'csrf', params );
	};

	Api.prototype.delete = function ( page, reason, watchPage ) {
		const params = {
			action: 'delete',
			title: page,
			reason: reason,
			watchlist: watchPage
		};

		return this.postWithToken( 'csrf', params );
	};

	/*
	 * Generic cache object, contains more specific cache objects
	 *
	 * The main ones are:
	 *  * link: data relating to a raw HTML link
	 *  * wiki: data for a link to a wiki
	 *
	 * Scoring functions can add their own data for use in the hook later on.
	 */
	function Cache() {
	}

	function oddEvenText( i ) {
		return ( i % 2 ) ? 'odd' : 'even';
	}

	function makeTableWithCols( tableCls, colCls ) {
		/* eslint-disable mediawiki/class-doc */
		var $cg = $( '<colgroup>' );
		var $tbody = $( '<tbody>' ).append( $cg );
		var $table = $( '<table>' ).addClass( tableCls )
			.append( $cg, $tbody );

		for ( var i = 0; i < colCls.length; i++ ) {
			var $col = $( '<col>' );
			if ( colCls[ i ] ) {
				$col.addClass( colCls[ i ] );
			}
			$cg.append( $col );
		}
		return $table;
		/* eslint-enable mediawiki/class-doc */
	}

	function makeDailyEventTableRows( items, cols, timestampFxn, rowFxn ) {
		var day = null;
		var timeOffset = 0;

		var trs = '';

		for ( var i = 0; i < items.length; i++ ) {
			var timestamp = timestampFxn( items[ i ] );
			var editDate = adjustDate( getDateFromTimestamp( timestamp ), timeOffset );

			var thisDay = dayFormat( editDate );
			var thisTime = timeFormat( editDate );
			if ( thisDay === day ) {
				thisDay = '';
			} else {
				day = thisDay;
			}

			// day header
			if ( thisDay ) {
				trs += makeColspanTableRow( 'popups_date', cols, thisDay );
			}

			var cells = rowFxn( items[ i ], thisTime );

			trs += "<tr class='popups_table_row_" + oddEvenText( i ) + "'>";

			for ( var d = 0; d < cells.length; d++ ) {
				trs += '<td>' + cells[ d ] + '</td>';
			}
			trs += '</tr>';
		}
		return trs;
	}

	/*
	 * Copy to clipboard
	 */
	function copyToClipboard( value ) {
		var tempInput = document.createElement( 'input' );
		tempInput.style = 'position: absolute; left: -1000px; top: -1000px';
		tempInput.value = value;
		document.body.appendChild( tempInput );
		tempInput.select();
		document.execCommand( 'copy' );
		document.body.removeChild( tempInput );
	}

	function makeCopyIcon( toCopy ) {
		var $icon = $( '<img>' )
			.attr( 'src', Popups.cfg.icons.copy )
			.addClass( 'popups_icon popups_icon_inline popups_icon_copy' )
			.on( 'click', function () {
				copyToClipboard( toCopy );
			} );
		return $icon;
	}

	function makeEditIcon( url ) {
		const $link = $( '<a>' )
			.attr( 'href', url );

		$( '<img>' )
			.attr( 'src', Popups.cfg.icons.edit )
			.addClass( 'popups_icon popups_icon_inline popups_icon_edit' )
			.appendTo( $link );

		return $link;
	}

	function constructEditsTable( serverName, revs, title, isContribs ) {
		var $table = makeTableWithCols( 'popups_rev_table',
			[ 'rev-links', 'rev-time', 'rev-user', 'rev-comment' ] );
		var $tb = $table.find( 'tbody' );
		// var timeOffset = 0;

		var timestampFxn = function ( rev ) {
			return rev.timestamp;
		};

		var cellsFxn = function ( rev, time ) {

			var revTitle = title || rev.title;

			var cells = [];

			if ( isContribs ) {
				var diffLink = getDiffLink( serverName, revTitle, rev.parentid, rev.revid, 'diff' );
				var histLink = getActionLink( serverName, revTitle, 'history', 'hist' );
				cells.push( bracketedLinkList( [ histLink, diffLink ] ) );
			} else {
				var curLink = getDiffLink( serverName, revTitle, rev.revid, revs[ 0 ].revid, 'cur' );
				var prevLink = getDiffLink( serverName, revTitle, rev.parentid, rev.revid, 'prev' );
				cells.push( bracketedLinkList( [ curLink, prevLink ] ) );
			}

			cells.push( getRevidLink( serverName, revTitle, rev, time ) );

			if ( isContribs ) {
				cells.push( getPageLink( serverName, revTitle ) );
			} else {
				cells.push( getUserLink( serverName, rev.user ) );
			}

			var minor = rev.minor ? '<span class="popups_edit_flags">m </span>' : '';
			cells.push( minor + rev.parsedcomment );
			return cells;
		};

		$tb.append( makeDailyEventTableRows( revs, 4, timestampFxn, cellsFxn ) );
		return $table;
	}

	/**
	 * Table for uploads - can't quite emulate Special:ListFiles, but good enough
	 *
	 * @param  {string} serverName
	 * @param  {Array} uploadLogEvents
	 * @return {jQuery}
	 */
	function constructUploadsTable( serverName, uploadLogEvents ) {
		const $table = makeTableWithCols( 'popups_uploads_table',
			[ 'upload-file', 'upload-action', 'upload-time' ] );

		const timestampFxn = function ( logEvent ) {
			return logEvent.timestamp;
		};

		const cellsFxn = function ( logEvent, time ) {
			const cells = [];

			cells.push( getPageLink( serverName, logEvent.title ) );

			cells.push( logEvent.action );

			cells.push( time );

			return cells;
		};

		$table
			.find( 'tbody' )
			.append( makeDailyEventTableRows( uploadLogEvents, 2, timestampFxn, cellsFxn ) );

		return $table;
	}

	function constructDiffView( compare ) {
		var $div = $( '<div>' ).addClass( 'popups_diff_view' );
		var $table = makeTableWithCols( 'popups_diff_table',
			[ 'diff-marker', 'diff-content', 'diff-marker', 'diff-content' ] );
		var $tbody = $table.find( 'tbody' );

		$div.append( $( '<div>' ).addClass( 'popups_diff_comment' )
			.append( compare.toparsedcomment ) );

		var sizediff = compare.tosize - compare.fromsize;
		if ( sizediff > 0 ) {
			sizediff = '+' + sizediff;
		}

		if ( compare.fromsize > 0 ) {
			sizediff += ' (' + ( 100 * ( compare.tosize - compare.fromsize ) / compare.tosize ).toFixed( 0 ) + '%)';
		}

		$div.append( $( '<div>' ).addClass( 'popups_diff_size' )
			.append( compare.fromsize + '&nbsp;→&nbsp;' + compare.tosize + ': ' + sizediff )
		);

		$( compare.body )
			.appendTo( $tbody );

		return $div.append( $table );
	}

	function genericTimestampFunction( item ) {
		return item.timestamp;
	}

	function constructLogEventView( serverName, events ) {
		var $table = makeTableWithCols( 'popups_log_event_table',
			[ 'le_type', 'le_title', 'le_timestamp', 'le_comment' ] );
		var $tb = $table.children( 'tbody' );
		// var timeOffset = 0;

		var cellsFxn = function ( evt, time ) {
			var cells = [
				evt.type,
				getPageLink( serverName, evt.title ),
				time,
				evt.parsedcomment
			];
			return cells;
		};

		$tb.append( makeDailyEventTableRows( events, 4, genericTimestampFunction, cellsFxn ) );
		return $table;
	}

	function constructAfLogView( serverName, log ) {

		var $table = makeTableWithCols( 'popups_log_event_table',
			[ 'le_type', 'le_title', 'le_timestamp', 'le_comment' ] );
		var $tb = $table.children( 'tbody' );

		function logTimestamp( logItem ) {
			return logItem.timestamp;
		}

		function logCells( logItem, time ) {
			var actionRes = logItem.action +
				( logItem.result ? ( '&nbsp;(' + logItem.result + ')' ) : '' );

			var cells = [ getPageLink( serverName, 'Special:AbuseLog/' + logItem.id,
				actionRes ) ];

			if ( logItem.revid ) {
				cells.push( getDiffLink( serverName, logItem.title, logItem.revid, 'prev', time ) );
			} else {
				cells.push( time );
			}

			cells.push( getPageLink( serverName, logItem.title ) );
			cells.push( getPageLink( serverName, 'Special:AbuseFilter/' + logItem.filter_id,
				logItem.filter + '(' + logItem.filter_id + ')' ) );
			return cells;
		}

		$tb.append( makeDailyEventTableRows( log, 4, logTimestamp, logCells ) );
		return $table;
	}

	function addToDl( $dl, dt, dd ) {
		$( '<dt>' ).append( dt ).appendTo( $dl );
		$( '<dd>' ).append( dd ).appendTo( $dl );
	}

	function constructAfLogEntry( serverName, entry ) {

		const $elem = $( '<div>' );
		const $dl = $( '<dl>' ).appendTo( $elem );

		const filterLink = getPageLink( serverName,
			'Special:AbuseFilter/' + entry.filter_id,
			entry.filter );

		addToDl( $dl, 'Filter', filterLink );
		addToDl( $dl, 'User name', getUserLink( serverName, entry.details.user_name ) );
		addToDl( $dl, 'User age', entry.details.user_age );
		addToDl( $dl, 'User edit count', entry.details.user_editcount );
		addToDl( $dl, 'Page age', entry.details.page_age );
		addToDl( $dl, 'Added lines', entry.details.added_lines );

		return $elem;
	}

	/*
	 * Recent changes and watchlists
	 */
	function constructRcLogView( serverName, log ) {
		var $table = makeTableWithCols( 'popups_rc_table',
			[ 'le_type', 'le_title', 'le_timestamp', 'le_comment' ] );
		var $tb = $table.children( 'tbody' );

		var cellsFxn = function ( logItem, time ) {
			var patrolled = '';
			if ( logItem.unpatrolled ) {
				patrolled = makeEnclosingTag( 'span', '!', 'popups_unpatrolled' );
			}
			if ( logItem.new ) {
				patrolled = makeEnclosingTag( 'span', 'N', 'popups_edit_flags' );
			}
			if ( logItem.minor ) {
				patrolled = makeEnclosingTag( 'span', 'm', 'popups_edit_flags' );
			}
			if ( logItem.bot ) {
				patrolled = makeEnclosingTag( 'span', 'b', 'popups_edit_flags' );
			}

			var cells = [
				logItem.type + '<br/>' + getUserLink( serverName, logItem.user ),
				getPageLink( serverName, logItem.title ),
				getDiffLink( serverName, logItem.title, logItem.revid, 'prev', time ),
				patrolled + '&nbsp;' + logItem.parsedcomment
			];
			return cells;
		};

		$tb.append( makeDailyEventTableRows( log, 4, genericTimestampFunction, cellsFxn ) );
		return $table;
	}

	function constructCategoryListView( serverName, cmems, listClass ) {

		var lis = makeOpenTag( 'ul', listClass );

		for ( var i = 0; i < cmems.length; i++ ) {
			lis += '<li>' + getPageLink( serverName, cmems[ i ].title ) + '</li>';
		}
		lis += '</ul>';
		return lis;
	}

	function constructBacklinksView( serverName, backlinks ) {
		var content = makeOpenTag( 'div', 'popups_backlinks' );

		if ( backlinks.length > 0 ) {
			content += '<ul>';

			for ( var i = 0; i < backlinks.length; i++ ) {
				content += '<li>' + getPageLink( serverName, backlinks[ i ].title ) + '</li>';
			}

			content += '</ul>';
		} else {
			content += makeEnclosingTag( 'span', 'No pages link to this page.' );
		}

		content += '</div>';

		return content;
	}

	function constructWikitextView( wikitext ) {
		var content = makeOpenTag( 'div', 'popups_wikitext' );

		if ( wikitext.length > 0 ) {
			content += makeEnclosingTag( 'pre', wikitext );
		}

		content += '</div>';

		return content;
	}

	function constructPageInfo( data ) {
		var content = makeOpenTag( 'div', 'popups_pageinfo popups_info_table_horz' );

		var $table = $( '<table>' );

		var makeRow = function ( h, d ) {
			if ( !d ) {
				return null;
			}
			return $( '<tr>' )
				.append( $( '<th>' )
					.attr( 'scope', 'row' )
					.append( h )
				)
				.append( $( '<td>' )
					.append( d )
				);
		};

		var protection = function ( parray ) {

			if ( !parray || parray.length === 0 ) {
				return 'none';
			}

			var strs = parray.map( function ( p ) {
				return p.type + ': ' + p.level + ' (' + p.expiry + ')';
			} );

			return strs.join( '<br>' );
		};

		var restrictions = function ( rarray ) {
			if ( !rarray || rarray.length === 0 ) {
				return 'none';
			}
			return rarray.join( ', ' );
		};

		// console.log( data );

		if ( data.missing ) {
			$table.append( makeRow( 'Missing', 'yes' ) );
		}

		$table
			.append( makeRow( 'Page ID', data.pageid ) )
			.append( makeRow( 'Last rev', data.lastrevid ) )
			.append( makeRow( 'Content model', data.contentmodel ) )
			.append( makeRow( 'Length', data.length ) )
			.append( makeRow( 'Protection', protection( data.protection ) ) )
			.append( makeRow( 'Restrictions', restrictions( data.restrictiontypes ) ) )
			.append( makeRow( 'Watchers', data.watchers ) );

		if ( data.visitingwatchers ) {
			$table.append( makeRow( 'Visiting watchers:', data.visitingwatchers ) );
		}

		content += $table.get( 0 ).outerHTML;

		content += '</div>';

		return Promise.resolve( content );
	}

	/*
	 * Basic "pass-though" recogniser that just copies the address
	 */
	Popups.cfg.recogniserHooks.push( {
		score: function ( /* $l, cache */ ) {
			// this is the very least we can do
			return 0;
		},
		canonical: function ( $l /* , cache */ ) {
			return Promise.resolve( {
				type: 'basic',
				href: $l.attr( 'href' ),
				display: $l.text(),
				canonical: $l.text()
			} );
		}
	} );

	function WikiCache() {
		this.title = undefined;
	}

	WikiCache.prototype.isBasePage = function () {
		return this.title === this.baseName;
	};

	function cacheUpdateWikiProps( $l, cache ) {
		if ( cache.wiki ) {
			return;
		}

		cache.wiki = new WikiCache();

		// normalise hrefs
		cache.link.href = cache.link.href.replace( /_/g, ' ' );

		if ( cache.link.url.pathname.startsWith( '/wiki/' ) ) {
			cache.wiki.title = decodeURIComponent( cache.link.url.pathname )
				.replace( /^\/wiki\//, '' )
				.replace( /_/g, ' ' );
		} else if ( cache.link.getParam( 'title' ) ) {
			cache.wiki.title = cache.link.getParam( 'title' );
		}

		cache.wiki.action = cache.link.getParam( 'action' ) || 'view';

		if ( cache.wiki.title ) {
			cache.wiki.baseName = cache.wiki.title.replace( /\/.*$/, '' );
			cache.wiki.namespace = cache.wiki.baseName.replace( /:.*$/, '' );
			cache.wiki.titleNoNs = cache.wiki.title.replace( cache.wiki.namespace + ':', '' );
		}

		const titleParts = cache.wiki.title.split( '/' );

		if ( cache.wiki.baseName === 'Special:Contributions' ) {
			cache.wiki.user = titleParts[ 1 ];
			cache.wiki.isContribs = true;
		} else if ( cache.wiki.baseName === 'Special:Log' &&
				cache.link.getParam( 'user' ) ) {
			cache.wiki.user = cache.link.getParam( 'user' );
		} else if ( cache.wiki.baseName === 'Special:Block' ) {
			cache.wiki.user = titleParts[ 1 ];
		} else if ( isNamespaceOrTalk( cache.wiki.namespace, 'User' ) ) {
			cache.wiki.user = cache.wiki.baseName.substring( cache.wiki.namespace.length + 1 );
		}

		if ( cache.link.url.hash ) {
			cache.wiki.section = cache.link.url.hash.substring( 1 );
		}

		// eslint-disable-next-line no-jquery/no-class-state
		cache.wiki.redlink = $l.hasClass( 'new' );
	}

	function cacheSiteInfo( api ) {
		const serverName = api.serverName;
		let siProm;
		if ( !Popups.siteinfo[ serverName ] ) {
			siProm = api
				.getSiteInfo( [ 'namespaces' ] )
				.then( ( si ) => {
					Popups.siteinfo[ serverName ] = si;
					return Popups.siteinfo[ serverName ];
				} );
		} else {
			siProm = Promise.resolve( Popups.siteinfo[ serverName ] );
		}
		return siProm;
	}

	function canonicaliseNs( serverName, localNs ) {
		const nses = Popups.siteinfo[ serverName ].namespaces;
		for ( const ns in nses ) {
			if ( nses[ ns ][ '*' ] === localNs ) {
				return nses[ ns ].canonical;
			}
		}
		return localNs;
	}

	/** On-wiki links */
	Popups.cfg.recogniserHooks.push( {
		score: function ( $l, cache ) {

			// no match - not a URL or not this server or a known foreign API
			if ( !cache.link.url ||
					!recognisedWikiServer( cache.link.url.hostname )
			) {
				return;
			}

			cacheUpdateWikiProps( $l, cache );
			return 100;
		},
		canonical: function ( $l, cache ) {

			cacheUpdateWikiProps( $l, cache );

			var display = cache.wiki.title;
			var href = cache.link.url.href;

			if ( cache.wiki.user ) {
				display = 'User:' + cache.wiki.user;
				href = getArticleUrl( display );
			}

			if ( cache.wiki.section ) {
				display += '#' + cache.wiki.section;
			}

			var oldid = cache.link.getParam( 'oldid' );
			if ( oldid ) {
				display += ' @' + oldid;
			}

			cache.wiki.local = cache.link.url.hostname === mw.config.get( 'wgServerName' );
			cache.wiki.serverName = cache.link.url.hostname;

			cache.wiki.api = new Api( cache.wiki.serverName );

			const isHash = href.endsWith( '/#' ) && cache.link.url.pathname === '/';

			const recognised = {
				type: isHash ? 'wiki_hash' : 'wiki_local',
				href: href,
				display: display,
				canonical: cache.wiki.title
			};
			// eslint-disable-next-line no-jquery/no-class-state
			if ( $l.hasClass( 'popups_wikitext' ) ) {
				recognised.type = 'wikitext';
			}

			return cacheSiteInfo( cache.wiki.api )
				.then( () => {
					// canonicalise namespace
					cache.wiki.namespace = canonicaliseNs( cache.wiki.serverName,
						cache.wiki.namespace );

					if ( cache.wiki.namespace !== 'Special' ) {
						recognised.editUrl = getWikiActionUrl( cache.wiki.serverName,
							cache.wiki.title, 'edit' );
					}
				} )
				.then( () => recognised );
		}
	} );

	function constructRawImageContent( src ) {
		var $content = $( '<div>' )
			.addClass( 'popups_rawimage' );

		$( '<img>' )
			.attr( 'src', src )
			.appendTo( $content );

		return $content;
	}

	function constructPageImageContent( serverName, titleNoNs ) {
		var size = 350;
		return new Api( serverName ).getPageThumbUrlForTitle( titleNoNs, size )
			.then( function ( imgUrl ) {
				return constructRawImageContent( imgUrl );
			} );
	}

	function constructMissingPageError() {
		return $( '<div>' )
			.addClass( 'popups_missingpage' )
			.append( 'Page does not exist' );
	}

	function constructGenericError( e ) {
		return $( '<div>' )
			.addClass( 'popups_error' )
			.append( e );
	}

	/**
	 * @param  {string} serverName
	 * @param  {string} title
	 * @param  {string} [section]
	 * @return {Promise} resolves with the content as jQuery
	 */
	function constructPageRender( serverName, title, section ) {

		const api = new Api( serverName );

		let renderProm;
		if ( section ) {
			renderProm = api.getSectionWithTitle( title, section )
				.then( function ( matchingSection ) {
					return api.getPageRender( title, matchingSection.index );
				} );
		} else {
			renderProm = api.getPageRender( title );
		}

		return renderProm
			.then( function ( parse ) {
				var $content = $( '<div>' )
					.addClass( 'popups_page_content' );

				const $parsed = $( parse.text );
				$content.append( $parsed );
				return $content;
			},
			function ( data ) {
				if ( data !== 'missingtitle' ) {
					return constructGenericError( `GET Failed: for ${title}: ` + data );
				}
				return constructMissingPageError();
			} );
	}

	function escapeHtml( html ) {
		return html
			.replace( /&/g, '&amp;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /"/g, '&quot;' )
			.replace( /'/g, '&#039;' );
	}

	/**
	 * Returns the page's Wikitext, as escaped HTML
	 *
	 * @param {string} serverName
	 * @param {string} title
	 * @param {number} oldid
	 * @return {Promise} the page wikitext as jQuery
	 */
	function constructPageWikitext( serverName, title, oldid ) {
		return new Api( serverName )
			.getPageWikitext( title, oldid )
			.then( function ( wikitext ) {
				return constructWikitextView( escapeHtml( wikitext ) );
			} );
	}

	/**
	 * Basic non-wiki content
	 */
	Popups.cfg.contentHooks.push( {
		name: 'basic non-wiki content',
		onlyType: 'basic',
		score: function () {
			return 0;
		},
		content: function ( $l, cache ) {
			// Note: for many (most?) external URLs, this will fail due to CORS
			return fetch( cache.link.href )
				.then( function ( data ) {

					const imageTypes = [ 'image/jpeg', 'image/png' ];

					// many images will load OK
					if ( imageTypes.indexOf( data.headers.get( 'content-type' ) ) !== -1 ) {
						return $( '<img>' )
							.attr( 'src', data.url );
					}

					throw new Error( 'Unsupported external site data' );
				} );
		}
	} );

	/**
	 * Mediawiki images
	 */
	Popups.cfg.contentHooks.push( {
		name: 'wikimedia image',
		onlyType: 'basic',
		score: function ( $l, cache ) {
			if ( cache.link.url.hostname === 'upload.wikimedia.org' ) {
				return 100;
			}
		},
		content: function ( $l, cache ) {

			const changeWikimediaThumbRes = function ( url, newRes ) {
				return url.replace( /(?<=\/(?:page\d+-)?)\d+(?=px-)/, newRes );
			};

			// downres to avoid massive images
			const url = changeWikimediaThumbRes( cache.link.url.href, Popups.cfg.imgWidth );

			return $( '<img>' )
				.addClass( 'popups_wikimedia_thumb' )
				.attr( 'src', url );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'hathitrust',
		onlyType: 'basic',
		score: function ( $l, cache ) {
			if ( cache.link.url.hostname === 'babel.hathitrust.org' ) {
				cache.hathi = {
					id: cache.link.getParam( 'id' ),
					seq: cache.link.getParam( 'seq' )
				};
				return 100;
			}
		},
		content: function ( $l, cache ) {
			if ( cache.hathi && cache.hathi.id ) {
				return $( '<code>' )
					.append( cache.hathi.id );
			}
		}
	} );

	/**
	 * Basic wiki content
	 */
	Popups.cfg.contentHooks.push( {
		name: 'basic wiki content',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			// special namespace pages don't have anything useful
			if ( cache.wiki.namespace !== 'Special' ) {
				// basic content is not very interesting, but it's the best we can
				// do for many pages
				return 0;
			}
		},
		content: function ( $l, cache ) {
			return constructPageRender( cache.wiki.serverName,
				cache.wiki.title, cache.wiki.section );
		}
	} );

	/*
	 * Wikitext
	 */
	Popups.cfg.contentHooks.push( {
		name: 'wikitext content',
		onlyType: 'wikitext',
		score: function ( $l, cache ) {
			// special namespace pages don't have anything useful
			if ( cache.wiki.namespace !== 'Special' ) {
				// matched wikitext exactly
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			var oldid = cache.link.getParam( 'oldid' );
			return constructPageWikitext( cache.wiki.serverName, cache.wiki.title, oldid );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'page NS content',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'Page' &&
					( cache.wiki.action === 'view' || cache.wiki.redlink ) ) {
				return 1000;
			}
		},
		content: function ( $l, cache ) {

			return new Api( cache.wiki.serverName )
				.ifPageExists( cache.wiki.title )
				.then( function ( exists ) {
					if ( !exists ) {
						// load page image
						return constructPageImageContent( cache.wiki.serverName,
							cache.wiki.titleNoNs );
					} else {
						return constructPageRender( cache.wiki.serverName, cache.wiki.title );
					}
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'old revision',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.link && cache.link.getParam( 'oldid' ) && !cache.link.getParam( 'diff' ) ) {
				cache.oldRev = {
					id: cache.link.getParam( 'oldid' )
				};
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			return new Api( cache.wiki.serverName )
				.getOldRevision( cache.oldRev.id )
				.then( ( content ) => {

					const $ret = $( '<div>' );

					const diffTemplate = mw.message( 'popups-diff-template', cache.oldRev.id ).plain();
					$( '<div>' )
						.append( $( '<code>' )
							.append( diffTemplate )
						)
						.append( makeCopyIcon( diffTemplate ) )
						.appendTo( $ret );

					$( '<div>' )
						.append( content )
						.appendTo( $ret );

					return $ret;
				},
				function ( data ) {
					console.error( 'GET Failed: for ', cache.wiki.title, data );
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'page history',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.action === 'history' ) {
				// outscore the "Plain" content hook for that page (1000)
				return 2000;
			}
		},
		content: function ( $l, cache ) {

			var ok = function ( page ) {
				var revs = page.revisions;

				var $table;
				if ( revs ) {
					$table = constructEditsTable( cache.wiki.serverName,
						revs, page.title, false );
				}
				return $table;
			};

			var fail = function () {
				console.error( 'GET Failed: for ', cache.wiki.title );
			};

			return new Api( cache.wiki.serverName )
				.getPageHistory( cache.wiki.title,
					Popups.cfg.logLimit.history || Popups.cfg.logLimit.default,
					[ 'tags', 'timestamp', 'user', 'parsedcomment', 'flags', 'ids', 'size' ]
				)
				.then( ok, fail );
		}
	} );

	/* extended page information */
	Popups.cfg.contentHooks.push( {
		name: 'page info',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.action === 'info' ) {
				// outscore the "Plain" content hook for that page (1000)
				return 2000;
			}
		},
		content: function ( $l, cache ) {

			return new Api( cache.wiki.serverName )
				.getPageInfo( cache.wiki.title,
					[ 'watchers', 'watched', 'visitingwatchers', 'varianttitles', 'url',
						'talkid', 'subjectid', 'preload', 'protection', 'notificationtimestamp',
						'linkclasses', 'displaytitle' ]
				)
				.then( function ( pageInfo ) {
					return constructPageInfo( pageInfo );
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'user contributions',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.isContribs ) {
				return 100;
			}
			if ( Popups.cfg.userContribsByDefault &&
					( cache.wiki.namespace === 'User' && cache.wiki.isBasePage() ) ) {
				return 100;
			}
		},
		content: function ( $l, cache ) {
			var user;
			if ( cache.wiki.isContribs ) {
				user = cache.wiki.title.split( '/' )[ 1 ];
			} else {
				user = cache.wiki.titleNoNs; // == wiki.baseName
			}

			var namespace;
			if ( cache.link ) {
				namespace = cache.link.getParam( 'namespace' );
			}

			return new Api( cache.wiki.serverName )
				.getUserContributions( user,
					[ 'ids', 'title', 'timestamp', 'parsedcomment', 'size', 'flags' ], namespace,
					Popups.cfg.logLimit.contribs || Popups.cfg.logLimit.default
				)
				.then( function ( contribRevs ) {
					var $table = constructEditsTable(
						cache.wiki.serverName, contribRevs, null, true );
					return $table;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'user files',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName.startsWith( 'Special:ListFiles' ) ||
						cache.wiki.baseName.startsWith( 'Special:AllMyUploads' )
			) {
				return 100;
			}
		},
		content: function ( $l, cache ) {

			if ( !cache.link ) {
				// TODO: throw something standard here
				return;
			}

			let user = cache.link.getParam( 'user' );
			if ( !user ) {
				user = cache.wiki.titleNoNs.split( '/' )[ 1 ];
			}

			if ( !user ) {
				return;
			}

			const type = 'all';

			return new Api( cache.wiki.serverName )
				.getUserFiles( user, type,
					Popups.cfg.logLimit.contribs || Popups.cfg.logLimit.default
				)
				.then( function ( userFiles ) {
					var $table = constructUploadsTable( cache.wiki.serverName, userFiles );
					return $table;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'diff view',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.link.hasAllParams( [ 'oldid', 'diff' ] ) ||
						cache.link.hasAllParams( [ 'undo', 'undoafter' ] ) ) {
				// this should out-score the "plain" content for that page
				// which is usually 1000)
				return 2000;
			}

			if ( cache.wiki.baseName.startsWith( 'Special:Diff' ) ) {
				cache.diff.diff = cache.wiki.title.replace( /^.*\//, '' );
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			var params = {
				action: 'compare',
				format: 'json',
				formatversion: 2,
				prop: [ 'diff', 'ids', 'title', 'parsedcomment', 'size' ].join( '|' )
			};

			if ( cache.link.getParam( 'undo' ) ) {

				params.fromrev = cache.link.getParam( 'undo' );
				params.torev = cache.link.getParam( 'undoafter' );
			} else {

				var oldid = cache.link.getParam( 'oldid' );
				var diff = cache.link.getParam( 'diff' );

				// taken from navigation popups
				switch ( diff ) {
					case 'cur':
						switch ( oldid ) {
							case null:
							case '':
							case 'prev':
								// this can only work if we have the title
								// cur -> prev
								params.fromtitle = cache.wiki.title;
								// params.fromrev = 'cur';
								params.torelative = 'prev';
								break;
							default:
								params.fromrev = oldid;
								params.torelative = 'cur';
								break;
						}
						break;
					case 'prev':
						if ( oldid ) {
							params.fromrev = oldid;
						} else {
							// params.fromtitle;
						}
						params.torelative = 'prev';
						break;
					case 'next':
						params.fromrev = oldid || 0;
						params.torelative = 'next';
						break;
					default:
						params.fromrev = oldid || 0;
						params.torev = diff || 0;
						break;
				}
			}

			return new Api( cache.wiki.serverName )
				.get( params )
				.then( function ( data ) {
					var $table = constructDiffView( data.compare );
					return $table;
				},
				function ( data ) {
					console.error( `GET Failed: for ${cache.wiki.title}`, data );
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'user log',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:Log' &&
					cache.link.getParam( 'user' ) ) {
				return 100;
			}
		},
		content: function ( $l, cache ) {
			return new Api( cache.wiki.serverName )
				.getUserLog( cache.link.getParam( 'user' ),
					[ 'ids', 'title', 'type', 'timestamp', 'parsedcomment' ],
					Popups.cfg.logLimit.userlog || Popups.cfg.logLimit.default )
				.then( function ( logEvents ) {
					var $table = constructLogEventView( cache.wiki.serverName, logEvents );
					return $table;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'user abuse filter log',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:AbuseLog' ) {
				var user = cache.link.getParam( 'wpSearchUser' );

				if ( user ) {
					cache.afLog = {
						user: user
					};
					return 100;
				}
			}
		},
		content: function ( $l, cache ) {

			return new Api( cache.wiki.serverName )
				.getUserAbuseFilterLog(
					cache.afLog.user,
					[ 'ids', 'title', 'action', 'timestamp', 'revid', 'filter', 'result' ],
					Popups.cfg.logLimit.userlog || Popups.cfg.logLimit.default
				)
				.then( function ( abuseLog ) {
					var $table = constructAfLogView( cache.wiki.serverName, abuseLog );
					return $table;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'abusefilter log entry',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:AbuseLog' ) {

				const parts = cache.wiki.title.split( '/' );

				if ( parts.length === 2 ) {
					cache.afLog = {
						entry: parseInt( parts[ 1 ] )
					};
					return 100;
				}
			}
		},
		content: function ( $l, cache ) {

			return new Api( cache.wiki.serverName )
				.getAbuseFilterLogEntry( cache.afLog.entry )
				.then( ( entry ) => {
					return constructAfLogEntry( cache.wiki.serverName, entry );
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'what links here',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:WhatLinksHere' ) {
				cache.whatLinksHere = {
					title: cache.wiki.title.substr( cache.wiki.title.indexOf( '/' ) + 1 )
				};
				return 1000;
			}
		},
		content: function ( $l, cache ) {

			var ok = function ( query ) {
				var pages = [];
				if ( query.backlinks ) {
					pages = pages.concat( query.backlinks );
				}
				if ( query.embeddedin ) {
					pages = pages.concat( query.embeddedin );
				}
				var $table = constructBacklinksView( cache.wiki.serverName, pages );
				return $table;
			};

			return new Api( cache.wiki.serverName )
				.getWhatLinksHere( cache.whatLinksHere.title,
					[ 'backlinks', 'embeddedin' ],
					Popups.cfg.linkLimit.default )
				.then( ok );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'what leaves here',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:WhatLeavesHere' ) {
				cache.whatLeavesHere = {
					title: cache.link.getParam( 'target' )
				};
				return 1000;
			}
		},
		content: function ( $l, cache ) {

			var ok = function ( page ) {
				if ( page.missing ) {
					return constructMissingPageError();
				} else {
					var pages = [];
					if ( page.templates ) {
						pages = pages.concat( page.templates );
					}
					if ( page.images ) {
						pages = pages.concat( page.images );
					}
					if ( page.links ) {
						pages = pages.concat( page.links );
					}
					var $table = constructBacklinksView( cache.wiki.serverName, pages );
					return $table;
				}
			};

			return new Api( cache.wiki.serverName )
				.getWhatLeavesHere( cache.whatLeavesHere.title,
					[ 'templates', 'images', 'links' ],
					Popups.cfg.linkLimit.default )
				.then( ok );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'image info',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.title && cache.wiki.namespace === 'File' &&
						cache.wiki.action === 'view' ) {
				return 1000;
			}
		},
		content: function ( $l, cache ) {

			var $content = $( '<div>' ).addClass( 'popups_img' );

			var $info = $( '<div>' )
				.addClass( 'popups_image_info' )
				.appendTo( $content );

			var alt = $l.attr( 'alt' ) || 'none';
			$info.append( makeClassedSpan( 'popups_info_title', 'Alt text: ' ), alt );

			return new Api( cache.wiki.serverName )
				.getImageInfo( cache.wiki.title,
					[ 'text', 'categories', 'externallinks', 'properties' ] )
				.then( function ( text ) {
					$content.append( $( '<div>' )
						.addClass( 'popups_image_content' )
						.append( text ) );
					return $content;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'recent changes',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.title ) {

				cache.recentChanges = {};

				const nses = cache.link.getParam( 'namespace' );
				if ( nses ) {
					cache.recentChanges.namespaces = nses.split( '|' );
				}

				if ( cache.wiki.baseName === 'Special:RecentChanges' ) {
					return 1000;
				}

				// This does not actually work, because there is no API for this
				// (requested since 2008: T17552)
				if ( cache.wiki.baseName === 'Special:RecentChangesLinked' ) {
					cache.recentChanges = {
						title: cache.wiki.title.substr( cache.wiki.title.indexOf( '/' ) + 1 )
					};
					return 1000;
				}

				cache.recentChanges = undefined;
			}
		},
		content: function ( $l, cache ) {
			var $content = $( '<div>' ).addClass( 'popups_rc' );

			var rcprops = [ 'title', 'timestamp', 'ids', 'user', 'parsedcomment', 'sizes' ];
			if ( haveRight( 'patrol' ) ) {
				rcprops.push( 'patrolled' );
			}

			return new Api( cache.wiki.serverName )
				.getRecentChanges( cache.recentChanges.title,
					{
						props: rcprops,
						types: Popups.cfg.rcTypes,
						limit: Popups.cfg.logLimit.rc || Popups.cfg.logLimit.default,
						ns: cache.recentChanges.namespaces
					} )
				.then( function ( recentChanges ) {
					$content.append( constructRcLogView( cache.wiki.serverName,
						recentChanges ) );
					return $content;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'watchlist',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:Watchlist' ) {
				cache.watchlist = {};

				const ns = cache.link.getParam( 'namespace' );
				if ( ns !== null ) {
					cache.watchlist.namespaces = ns.split( ';' );
				}

				return 1000;
			}
		},
		content: function ( $l, cache ) {
			var $content = $( '<div>' ).addClass( 'popups_watchlist' );

			var wlProps = [ 'title', 'timestamp', 'ids', 'user', 'parsedcomment', 'flags', 'sizes' ];
			if ( haveRight( 'patrol' ) ) {
				wlProps.push( 'patrolled' );
			}

			return new Api( cache.wiki.serverName )
				.getWatchlistEntries(
					mw.config.get( 'wgUserName' ),
					wlProps,
					Popups.cfg.watchlistTypes,
					cache.watchlist.namespaces,
					Popups.cfg.logLimit.watchlist || Popups.cfg.logLimit.default
				)
				.then( function ( watchlist ) {
					$content.append( constructRcLogView( cache.wiki.serverName, watchlist ) );
					return $content;
				} );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'category members',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'Category' ) {
				return 1000;
			}
		},
		content: function ( $l, cache ) {

			var handleCatMembers = function ( categorymembers ) {
				var $content = $( '<div>' ).addClass( 'popups_catmember' );

				$content.append( $( '<h1>' ).append( 'Parent categories' ) );

				$content.append( $( '<h1>' ).append( 'Category members' ) );

				$content.append( constructCategoryListView( cache.wiki.serverName,
					categorymembers, 'hlist' ) );

				return $content;
			};

			return new Api( cache.wiki.serverName )
				.getCategoryMembers( cache.wiki.title,
					Popups.cfg.linkLimit.categoryMembers || Popups.cfg.linkLimit.default )
				.then( handleCatMembers );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'raw page image',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.link && cache.link.href && cache.link.href.match( /page\d+-\d+px/ ) ) {
				// outweigh "normal" raw image content, which might go to File: page
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			return constructRawImageContent( cache.link.href );
		}
	} );

	Popups.cfg.contentHooks.push( {
		name: 'random',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.baseName === 'Special:Random' ||
				cache.wiki.baseName === 'Special:RandomRootpage' ) {
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			var $content = $( '<div>' )
				.addClass( 'popups-random' );
			var $ul = $( '<ul>' ).appendTo( $content );

			/* Dirty hack: T274219 */
			var filterRoots = function ( page ) {
				return ( page.ns !== 0 ) || page.title.indexOf( '/' ) === -1;
			};

			// TODO: invalid outside enWS
			var ns = [];
			switch ( cache.wiki.title.substr( cache.wiki.title.lastIndexOf( '/' ) + 1 ) ) {
				case 'Index':
					ns.push( 106 );
					break;
				case 'Author':
					ns.push( 102 );
					break;
				default:
					ns.push( 0 );
			}

			return new Api( cache.wiki.serverName )
				.getRandomPages( ns,
					Popups.cfg.linkLimit.randomPages || Popups.cfg.linkLimit.default )
				.then( function ( pages ) {
					pages = pages.filter( filterRoots );

					for ( var i = 0; i < pages.length; ++i ) {
						$( '<li>' ).append( $( '<a>' )
							.attr( 'href', getArticleUrl( pages[ i ].title ) )
							.append( pages[ i ].title ) )
							.appendTo( $ul );
					}

					return $content;
				} );
		}
	} );

	/*
	 * Convert Special:EntityPage into normal content
	 */
	Popups.cfg.contentHooks.push( {
		name: 'wikibase entity',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'Special' &&
				cache.wiki.titleNoNs.startsWith( 'EntityPage/' ) ) {
				cache.entityPage = {
					qid: cache.wiki.titleNoNs.split( '/' ).at( -1 )
				};
				return 2000;
			}
		},
		content: function ( $l, cache ) {
			return constructPageRender( cache.wiki.serverName, cache.entityPage.qid );
		}
	} );

	function deleteFromPreset( api, page, delInfo ) {

		let reason = '';
		if ( delInfo.message ) {
			/* eslint-disable-next-line mediawiki/msg-doc */
			reason = mw.msg( delInfo.message );
		} else if ( delInfo.reason ) {
			reason = delInfo.reason;
		}

		let promise;
		if ( !delInfo.noconfirm ) {
			promise = confirmAction(
				mw.msg( 'popups-delete-confirm', page, reason )
			);
		} else {
			promise = Promise.resolve( true );
		}

		promise.then( function () {

			api.delete( page, reason, delInfo.watchlist )
				.then(
					function () {
						mw.notify( mw.msg( 'popups-delete-ok', page ) );
					},
					function () {
						mw.notify( mw.msg( 'popups-delete-failed', page ) );
					}
				);
		} );
	}

	function clickableLink( text, clickHandler ) {
		return $( '<a>' )
			.append( text )
			.on( 'click', clickHandler );
	}

	Popups.cfg.contentHooks.push( {
		name: 'delete',
		score: function ( $l, cache ) {
			if ( cache.recognised.type === 'wiki_local' &&
					cache.wiki.action === 'delete'
			) {
				return 100;
			}
		},
		content: function ( $l, cache ) {

			const $ul = $( '<ul>' );
			const api = new Api( cache.wiki.serverName );

			for ( const delInfo of Popups.cfg.deletes ) {
				$( '<li>' )
					.append( clickableLink( delInfo.name,
						function () {
							deleteFromPreset( api, cache.wiki.title, delInfo );
						} )
					)
					.appendTo( $ul );
			}

			return $ul;
		}
	} );

	var purgePage = function ( serverName, page ) {

		var notify = function ( ok ) {
			if ( ok ) {
				mw.notify( 'Page purged.' );
			} else {
				mw.notify( 'Purge failed.', { type: 'error' } );
			}
		};

		new Api( serverName )
			.doPurgePage( page )
			.then( function ( data ) {
				notify( data.purge[ 0 ].purged );
			}, function () {
				notify( false );
			} );
	};

	function makeEditHistTuple( serverName, title, text, isLocal ) {

		var actionFactory, urlFactory;
		if ( isLocal ) {
			urlFactory = function ( urlTitle ) {
				return getWikiArticleUrl( serverName, urlTitle );
			};
			actionFactory = function ( urlTitle, action ) {
				return getWikiActionUrl( serverName, urlTitle, action );
			};
		} else {
			urlFactory = getCommonsArticleUrl;
			actionFactory = getCommonsActionUrl;
		}

		return [
			{
				text: text || title,
				href: urlFactory( title )
			},
			{
				text: 'e',
				href: actionFactory( title, 'edit' ),
				nopopup: true
			},
			{
				text: 'h',
				href: actionFactory( title, 'history' )
			}
		];
	}

	function makeFileDirectTuple( file, directUrl, isLocal, opts ) {
		var actionFactory, urlFactory;
		if ( isLocal ) {
			urlFactory = getArticleUrl;
			actionFactory = getActionUrl;
		} else {
			urlFactory = getCommonsArticleUrl;
			actionFactory = getCommonsActionUrl;
		}

		return [
			{
				text: opts.name || 'file',
				href: urlFactory( 'File:' + file )
			},
			{
				text: 'e',
				href: actionFactory( file, 'edit' ),
				nopopup: true
			},
			{
				text: 'h',
				href: actionFactory( file, 'history' )
			},
			{
				text: '↓',
				href: directUrl,
				nopopup: true
			},
			{
				text: 'reupload',
				href: getReuploadUrl( file, isLocal )
			}
		];
	}

	Popups.cfg.actionHooks.push( {
		name: 'basic actions',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			// if not a page on the current wiki, or a Js thing, basic content
			// doesn't work
			if ( !cache.wiki.title ||
					cache.wiki.namespace === 'Special' ) {
				return;
			}

			// the basic actions are usually fairly important
			return 1000;
		},
		actions: function ( $l, cache ) {
			var actions = [];

			var altNs;
			var altDisplay;

			var fn = cache.wiki.titleNoNs;

			const api = new Api( cache.wiki.serverName );

			var isLocalPromise;
			if ( cache.wiki.namespace === 'File' && cache.wiki.local ) {
				// files can be non-local even if the link says they are local
				isLocalPromise = api.getFileIsLocal( fn );
			} else {
				isLocalPromise = Promise.resolve( true );
			}

			return isLocalPromise.then( function ( isLocal ) {
				var serverName;

				if ( !isLocal ) {
					serverName = Popups.commons.serverName;
				} else {
					serverName = cache.wiki.serverName;
				}

				if ( cache.wiki.namespace.endsWith( ' talk' ) ) {
					altNs = cache.wiki.namespace.replace( ' talk', '' );
					altDisplay = 'article';
				} else {
					altNs = cache.wiki.namespace + ' talk';
					altDisplay = 'talk';
				}

				var oldid;
				if ( cache.link ) {
					oldid = cache.link.getParam( 'oldid' );
				}

				const basics = [];

				if ( cache.wiki.section ) {
					basics.push( {
						text: 'page',
						href: getWikiArticleUrl( serverName, cache.wiki.title )
					} );
				}

				basics.push( {
					text: 'edit',
					href: getWikiActionUrl( serverName, cache.wiki.title, 'edit',
						{ oldid: oldid } ),
					nopopup: true
				} );

				basics.push( {
					text: 'wikitext',
					href: oldid ?
						getWikiActionUrl( serverName, cache.wiki.title, null,
							{ oldid: oldid } ) :
						getWikiArticleUrl( serverName, cache.wiki.title ),
					class: [ 'popups_wikitext' ]
				} );

				basics.push( {
					text: 'history',
					href: getWikiActionUrl( serverName, cache.wiki.title, 'history' )
				} );

				basics.push( makeEditHistTuple( serverName,
					altNs + ':' + cache.wiki.titleNoNs, altDisplay, isLocal )
				);

				basics.push( {
					text: mw.msg( 'popups-actions-info' ),
					href: getWikiActionUrl( serverName, cache.wiki.title, 'info' )
				} );

				basics.push( {
					text: mw.msg( 'popups-actions-move' ),
					href: getWikiArticleUrl( serverName,
						'Special:MovePage/' + cache.wiki.title )
				} );

				const deletes = [
					// The main delete action
					{
						text: mw.msg( 'popups-actions-delete' ),
						href: getWikiActionUrl( serverName, cache.wiki.title, 'delete' )
					}
				];

				for ( const delInfo of Popups.cfg.deletes ) {
					if ( delInfo.asAction ) {
						deletes.push( {
							text: delInfo.name,
							click: function () {
								deleteFromPreset( api, cache.wiki.title, delInfo );
							}
						} );
					}
				}

				basics.push( deletes );

				actions.push( basics );

				var unWatchPage = function ( watch, title ) {

					new Api( cache.wiki.serverName )
						.watchPage( title, watch )
						.then( function () {
							var verb = watch ? 'added to' : 'removed from';
							mw.notify( "'" + title + "' " + verb + ' watchlist.' );
						},
						function ( data ) {
							mw.notify( 'Watchlist edit failed: ' + data, {
								type: 'error'
							} );
						} );
				};

				actions.push( [ {
					text: 'most recent edit',
					href: getDiffUrl( serverName, cache.wiki.title, 'prev', 'cur' )
				},
				{
					text: 'related changes',
					href: getWikiArticleUrl( serverName, 'Special:RecentChangesLinked/' + cache.wiki.title )
				},
				[
					{
						text: 'un',
						href: '#',
						click: function () { unWatchPage( false, cache.wiki.title ); }
					},
					{
						text: 'watch',
						href: '#',
						click: function () { unWatchPage( true, cache.wiki.title ); }
					}
				]
				] );
				actions.push( [ {
					text: 'what links here',
					href: getWikiArticleUrl( serverName, 'Special:WhatLinksHere/' + cache.wiki.title )
				},
				{
					text: 'what leaves here',
					href: getWikiArticleUrl( serverName, 'Special:WhatLeavesHere?target=' + cache.wiki.title )
				},
				{
					text: 'purge',
					click: function () {
						purgePage( serverName, cache.wiki.title );
					},
					nopopup: true
				}
				] );

				return actions;
			} ); // end local then()
		}
	} );

	function blockByPresetInfo( api, user, blockInfo ) {

		let reason;
		if ( blockInfo.message ) {
			// eslint-disable-next-line mediawiki/msg-doc
			reason = mw.msg( blockInfo.message );
		} else {
			// use the reason the user gave
			reason = blockInfo.reason;
		}

		let promise;
		if ( !blockInfo.noconfirm ) {
			promise = confirmAction(
				mw.msg( 'popups-block-confirm', user, reason )
			);
		} else {
			promise = Promise.resolve( true );
		}

		promise.then( function ( confirmed ) {
			if ( !confirmed ) {
				return;
			}

			api
				.blockUser( user, blockInfo.expiry,
					{
						reason: reason,
						allowusertalk: blockInfo.allowusertalk
					} )
				.then(
					function () {
						mw.notify( mw.msg( 'popups-user-blocked', user ) );
					},
					function () {
						mw.notify(
							mw.msg( 'popups-user-block-failed', user ),
							{ type: 'error' }
						);
					}
				);
		} );
	}

	Popups.cfg.actionHooks.push( {
		name: 'user',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.user ) {
				return 2000; // at top
			}
		},
		actions: function ( $l, cache ) {

			console.assert( cache.wiki.user );

			const actions = [];
			const acctActions = [];

			const api = new Api( cache.wiki.serverName );

			if ( !cache.wiki.isContribs ) {
				actions.push( [
					{
						text: 'contribs',
						href: getWikiArticleUrl( cache.wiki.serverName,
							'Special:Contributions/' + cache.wiki.user )
					},
					{
						text: 'com',
						href: getWikiArticleUrl( Popups.commons.serverName,
							'Special:Contributions/' + cache.wiki.user )
					}
				] );
			} else if ( cache.wiki.serverName !== 'commons.wikimedia.org' ) {
				actions.push( {
					text: 'com',
					href: getWikiArticleUrl( Popups.commons.serverName,
						'Special:Contributions/' + cache.wiki.user )
				} );
			}

			if ( cache.wiki.serverName !== 'commons.wikimedia.org' ) {
				actions.push( [
					{
						text: '↑',
						href: getWikiArticleUrl( cache.wiki.serverName,
							'Special:ListFiles/' + cache.wiki.user )
					},
					{
						text: 'com',
						href: getWikiArticleUrl( Popups.commons.serverName,
							'Special:ListFiles/' + cache.wiki.user )
					}
				] );
			} else {
				actions.push( {
					text: '↑',
					href: getWikiArticleUrl( cache.wiki.serverName,
						'Special:ListFiles/' + cache.wiki.user )
				} );
			}

			actions.push( {
				text: 'user log',
				href: getWikiActionUrl( cache.wiki.serverName, 'Special:Log', null,
					{ user: cache.wiki.user } )
			} );
			actions.push( {
				text: 'abuse log',
				href: getWikiActionUrl( cache.wiki.serverName, 'Special:AbuseLog', null,
					{ wpSearchUser: cache.wiki.user } )
			} );

			acctActions.push( {
				text: 'Xtools',
				href: 'https://xtools.wmflabs.org/ec/' + cache.wiki.serverName + '/' + cache.wiki.user
			} );
			acctActions.push( {
				text: 'SUL',
				href: getWikiArticleUrl( cache.wiki.serverName, 'Special:CentralAuth/' + cache.wiki.user )
			} );
			acctActions.push( {
				text: 'global',
				href: 'https://tools.wmflabs.org/guc/index.php?user=' + cache.wiki.user + '&blocks=true'
			} );

			if ( haveRight( 'block' ) ) {

				const blocks = [];

				blocks.push( {
					text: 'un',
					href: getWikiArticleUrl( cache.wiki.serverName,
						'Special:Ublock/' + cache.wiki.user )
				} );

				blocks.push( {
					text: 'block',
					href: getWikiArticleUrl( cache.wiki.serverName,
						'Special:Block/' + cache.wiki.user )
				} );

				for ( const blockInfo of Popups.cfg.blocks ) {
					blocks.push( {
						text: blockInfo.name,
						click: function () {
							blockByPresetInfo( api, cache.wiki.user, blockInfo );
						}
					} );
				}

				acctActions.push( blocks );
			}

			if ( haveRight( 'nuke' ) ) {
				acctActions.push( {
					text: mw.msg( 'popups-action-mass-delete' ),
					href: getWikiActionUrl( cache.wiki.serverName,
						'Special:Nuke', null,
						{ target: cache.wiki.user }
					)
				} );
			}

			return [ actions, acctActions ];
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'prefs',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.title === 'Special:Preferences' ) {
				return 2000; // at top
			}
		},
		actions: function ( $l, cache ) {
			var prefLink = getWikiArticleUrl( cache.wiki.serverName, 'Special:Preferences' );

			var prefSecs = [
				[ 'user profile', '#mw-prefsection-personal' ],
				[ 'appearance', '#mw-prefsection-rendering' ],
				[ 'editing', '#mw-prefsection-editing' ],
				[ 'recent changes', '#mw-prefsection-rc' ],
				[ 'watchlist', '#mw-prefsection-watchlist' ],
				[ 'gadgets', '#mw-prefsection-gadgets' ],
				[ 'search', '#mw-prefsection-searchoptions' ],
				[ 'beta features', '#mw-prefsection-betafeatures' ],
				[ 'notifications', '#mw-prefsection-echo' ]
			];

			var actions = [];

			for ( var i = 0; i < prefSecs.length; ++i ) {
				actions.push( {
					text: prefSecs[ i ][ 0 ],
					href: prefLink + prefSecs[ i ][ 1 ],
					nopopup: true
				} );
			}
			return actions;
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'contributions',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.isContribs ) {
				return 2000; // at top
			}
		},
		actions: function ( $l, cache ) {
			var contribLink = getWikiArticleUrl( cache.wiki.serverName,
				'Special:Contributions/' + cache.wiki.user );

			var actions = [
				[]
			];

			var nsList = Popups.cfg.nsLinkList.default;

			for ( var i = 0; i < nsList.length; ++i ) {
				var ns = nsList[ i ];
				var nsName = mw.config.get( 'wgFormattedNamespaces' )[ ns ];
				var url = contribLink + '?namespace=' + ns;
				actions[ 0 ].push( {
					text: nsName || 'Main',
					href: url
				} );
			}
			return actions;
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'file',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'File' ) {
				return 0; // at end
			}
		},
		actions: function ( $l, cache ) {
			var fn = cache.wiki.titleNoNs;

			return new Api( cache.wiki.serverName )
				.getFileImageinfo( fn, [ 'url' ] )
				.then( function ( imgPage ) {
					const isLocal = imgPage.imagerepository !== 'shared';
					const directUrl = imgPage.imageinfo[ 0 ].url;

					var actions = [
						{
							text: '↓',
							href: directUrl,
							nopopup: true
						},
						{
							text: 'reupload',
							href: getReuploadUrl( fn, isLocal )
						}
					];

					return [ actions ];
				} );
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'watchlist',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.title === 'Special:Watchlist' ) {
				return 100;
			}
		},
		actions: function ( $l, cache ) {
			const nsActions = [];

			for ( const ns of Popups.cfg.nsLinkList.default ) {
				nsActions.push( {
					href: getWikiActionUrl( cache.wiki.serverName, 'Special:Watchlist',
						null, { namespace: ns } ),
					text: mw.config.get( 'wgFormattedNamespaces' )[ ns ] || 'Main'
				} );
			}

			return [ nsActions ];
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'recentchanges',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.title === 'Special:RecentChanges' ) {
				return 100;
			}
		},
		actions: function ( $l, cache ) {
			const nsActions = [];

			for ( const ns of Popups.cfg.nsLinkList.default ) {
				nsActions.push( {
					href: getWikiActionUrl( cache.wiki.serverName, 'Special:RecentChanges',
						null, { namespace: ns } ),
					text: mw.config.get( 'wgFormattedNamespaces' )[ ns ] || 'Main'
				} );
			}

			return [ nsActions ];
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'pp-index',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'Index' ) {
				return 0; // at end
			}
		},
		actions: function ( $l, cache ) {
			var fn = cache.wiki.titleNoNs;

			return new Api( cache.wiki.serverName )
				.getFileImageinfo( fn, [ 'url' ] )
				.then( function ( imgPage ) {
					var isLocal = imgPage.imagerepository !== 'shared';

					var actions = [];

					actions.push( makeFileDirectTuple( fn, imgPage.imageinfo[ 0 ].url, isLocal,
						{ name: 'file' } ) );

					// saves actually looking up the content model
					if ( !fn.endsWith( '.css' ) ) {
						actions.push( makeEditHistTuple( cache.wiki.serverName,
							'Index:' + fn + '/styles.css', 'styles.css', true ) );
					}
					return [ actions ];
				} );
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'pp-page',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {
			if ( cache.wiki.namespace === 'Page' ) {
				return 0; // at end
			}
		},
		actions: function ( $l, cache ) {

			var preloadImageHead = function ( url ) {
				$.ajax( {
					type: 'HEAD',
					url: url
				} );
			};

			var fn = cache.wiki.titleNoNs.replace( /\/\d+$/, '' );
			var pg = cache.wiki.titleNoNs.replace( /^.*\/(\d+)$/, '$1' );
			var size = 1024;

			return new Api( cache.wiki.serverName )
				.getPageImageUrl( fn, pg, size )
				.then( function ( data ) {

					var imgActions = [];
					// var num = parseInt( pg );

					if ( pg > 1 ) {
						imgActions.push( {
							text: 'prev page',
							href: getArticleUrl( 'Page:' + fn + '/' + ( parseInt( pg ) - 1 ) )
						} );
					}

					const pageInfo = data.query.pages[ 0 ];
					const imageInfo = pageInfo.imageinfo[ 0 ];
					const pageImageUrl = imageInfo.thumburl;

					imgActions.push( {
						text: 'page image',
						href: pageImageUrl
					} );

					preloadImageHead( pageImageUrl );

					const isLocal = pageInfo.imagerepository !== 'shared';

					imgActions.push( {
						text: 'next page',
						href: getArticleUrl( 'Page:' + fn + '/' + ( parseInt( pg ) + 1 ) )
					} );

					const indexActions = [
						makeEditHistTuple( cache.wiki.serverName,
							'Index:' + fn, 'index', true ),
						makeFileDirectTuple( fn, imageInfo.url, isLocal,
							{
								name: 'file'
							} ),
						makeEditHistTuple( cache.wiki.serverName,
							'Index:' + fn + '/styles.css', 'styles', true )
					];

					return [ imgActions, indexActions ];
				} );
		}
	} );

	Popups.cfg.actionHooks.push( {
		name: 'diff',
		onlyType: 'wiki_local',
		score: function ( $l, cache ) {

			if ( cache.link.getParam( 'oldid' ) ) {
				return 2000;
			}
		},
		actions: function ( $l, cache ) {
			var revPromise;
			var diffInt = parseInt( cache.link.getParam( 'diff' ) );

			if ( cache.link.getParam( 'oldid' ) ) {

				if ( !cache.link.getParam( 'diff' ) || isNaN( diffInt ) ) {
					// easy - the rev is the oldid
					revPromise = Promise.resolve( cache.link.getParam( 'oldid' ) );
				}
			}

			const api = new Api( cache.wiki.serverName );

			if ( cache.link.getParam( 'diff' ) === 'cur' ) {
				// we have to get the current ID as an integer
				revPromise = api
					.getCurrentRevs( cache.wiki.title )
					.then( function ( revs ) {
						return revs[ 0 ].revid;
					} );
			}

			if ( !revPromise && !isNaN( diffInt ) ) {
				revPromise = Promise.resolve( cache.link.getParam( 'diff' ) );
			}

			if ( !revPromise ) {
				return;
			}

			return revPromise.then( function ( rev ) {

				var promises = [];

				var getRevertAction = function ( compare ) {
					var revLink = getSpecialDiffLink( compare.torevid );
					var revertSummary = 'Revert to revision ' + revLink +
							' dated ' + getDatetimeFromTimestamp( compare.totimestamp ) +
							' by ' + getUserLink( cache.wiki.serverName, compare.touser );
					var revertToUrl = getWikiActionUrl( cache.wiki.serverName,
						cache.wiki.title, 'edit', {
							summary: revertSummary,
							oldid: compare.torevid
						} );

					var revertAction = {
						text: 'revert to',
						href: revertToUrl,
						nopopup: true
					};
					return revertAction;
				};

				var getUndoAction = function ( compare ) {
					var url = getWikiActionUrl( cache.wiki.serverName, cache.wiki.title, 'edit',
						{ undo: compare.torevid, undoafter: compare.fromrevid } );

					return {
						text: 'undo',
						href: url
					};
				};

				var getRollbackAction = function ( compare ) {
					return {
						text: 'rollback',
						click: function () {
							api
								.rollbackEdit( compare.toid, compare.touser )
								.then( function () {
									mw.notify( 'Edit rolled back.' );
								},
								function ( error ) {
									mw.notify( 'Rollback failed: ' + error, {
										type: 'error'
									} );
								} );
						}
					};
				};

				var getPatrolAction = function ( revToPatrol ) {
					return {
						text: 'patrol',
						click: () => {
							api
								.patrolRevision( revToPatrol )
								.then( () => {
									mw.notify( `Revision on page ${cache.wiki.title}`,
										{
											type: 'success',
											title: 'Patrolled'
										} );
								},
								( err, info ) => {
									mw.notify( `Revision on page ${cache.wiki.title}:` +
										'\n' + info.error.info,
									{
										title: 'Failed to patrol',
										type: 'error'
									} );
								} );
						}
					};
				};

				var thankAction = function ( revToThank ) {
					return {
						text: 'thank',
						click: function () {
							api
								.thankForRevision( revToThank )
								.then( function () {
									mw.notify( 'User thanked.' );
								},
								function ( error ) {
									mw.notify( 'Thank failed: ' + error, {
										type: 'error'
									} );
								} );
						}
					};
				};

				var undoProm = new Api( cache.wiki.serverName )
					.getSingleEditCompare( rev, [ 'ids', 'user', 'timestamp' ] )
					.then( function ( compare ) {
						var actions = [
							getUndoAction( compare ),
							getRevertAction( compare )
						];

						if ( haveRight( 'rollback' ) ) {
							actions.push( getRollbackAction( compare ) );
						}

						if ( haveRight( 'patrol' ) ) {
							actions.push( getPatrolAction( rev ) );
						}

						actions.push( thankAction( rev ) );

						return actions;
					},
					function () {
						var actions = [];

						if ( haveRight( 'patrol' ) ) {
							actions.push( getPatrolAction( rev ) );
						}

						actions.push( thankAction( rev ) );

						return actions;
					} );

				promises.push( undoProm );

				return Promise.all( promises )
					.then( function ( values ) {
						var actions = [];
						for ( var i = 0; i < values.length; ++i ) {
							[].push.apply( actions, values[ i ] );
						}
						return [ actions ];
					} );
			} );
		}
	} );

	function sortByScore( a, b ) {
		return b[ 1 ] - a[ 1 ];
	}

	function recognise( $elem, cache ) {

		const scores = [];
		const proms = [];

		for ( const hook of Popups.cfg.recogniserHooks ) {
			proms.push(
				// it's OK if it returns a value, promisify it
				Promise.resolve( hook.score( $elem, cache ) )
					.then(
						function ( score ) {
							if ( score !== undefined ) {
								scores.push( [ hook, score ] );
							}
						}
					)
			);
		}

		return Promise.all( proms )
			.then( function () {

				scores.sort( sortByScore );

				if ( scores.length > 0 ) {
					const topScore = scores[ 0 ][ 1 ];
					return scores[ 0 ][ 0 ].canonical( $elem, cache )
						.then( function ( recognised ) {
							cache.recognised = recognised;
							return { recognised, score: topScore };
						} );
				} else {
					return Promise.reject();
				}
			} );
	}

	function getContent( $elem, cache ) {
		const scores = [];
		const proms = [];

		for ( const hook of Popups.cfg.contentHooks ) {

			// skip content hooks without compatible recognised types
			let onlyTypes = hook.onlyType;
			if ( onlyTypes ) {
				if ( !Array.isArray( onlyTypes ) ) {
					onlyTypes = [ onlyTypes ];
				}

				if ( onlyTypes && onlyTypes.indexOf( cache.recognised.type ) === -1 ) {
					continue;
				}
			}

			proms.push(
				// it's OK if it returns a value, promisify it
				Promise.resolve( hook.score( $elem, cache ) )
					.then(
						function ( score ) {
							if ( score !== undefined ) {
								scores.push( [ hook, score ] );
							}
						}
					)
			);
		}

		return Promise.all( proms )
			.then( function () {
				scores.sort( sortByScore );

				if ( scores.length > 0 ) {
					const topScore = scores[ 0 ][ 1 ];
					console.log( `Content hook: ${scores[ 0 ][ 0 ].name}, score: ${topScore}` );

					return scores[ 0 ][ 0 ].content( $elem, cache );
				} else {
					return Promise.reject( 'No scoring content found' );
				}
			} );
	}

	function getActions( $elem, cache, callback ) {
		const scores = [];
		const proms = [];

		for ( const hook of Popups.cfg.actionHooks ) {

			// skip hooks without compatible recognised types
			let onlyTypes = hook.onlyType;
			if ( onlyTypes ) {
				if ( !Array.isArray( onlyTypes ) ) {
					onlyTypes = [ onlyTypes ];
				}

				if ( onlyTypes && onlyTypes.indexOf( cache.recognised.type ) === -1 ) {
					continue;
				}
			}

			proms.push(
				// it's OK if it returns a value, promisify it
				Promise.resolve( hook.score( $elem, cache ) )
					.then(
						function ( score ) {
							if ( score !== undefined ) {
								scores.push( [ hook, score ] );
							}
						}
					)
			);
		}

		// sort the returns and resolve
		// TODO: should we return ASAP above, and let the caller sort, or
		// will that make things too jumpy?
		return Promise.all( proms )
			.then( function () {
				scores.sort( sortByScore );

				for ( var ii = 0; ii < scores.length; ii++ ) {
					const actions = scores[ ii ][ 0 ].actions( $elem, cache );

					Promise.resolve( actions )
						.then( function ( resolvedActions ) {
							if ( resolvedActions && resolvedActions.length ) {
								callback( resolvedActions );
							}
						} );
				}
			} );
	}

	function LinkCache( href, baseHref ) {
		this.href = href;

		if ( href ) {

			// tack on fragments
			if ( href.startsWith( '#' ) ) {
				href = baseHref.replace( /#.*$/, '' ) + href;
			}

			try {
				this.url = new URL( href, baseHref );

				if ( this.url.search ) {
					this.params = this.url.searchParams;
				}
			} catch ( e ) {
				// this is OK, we just won't have a URL
			}
		}
	}

	LinkCache.prototype.getParam = function ( param ) {
		return this.params ? this.params.get( param ) : null;
	};

	LinkCache.prototype.hasAllParams = function ( params ) {

		if ( !this.params ) {
			return false;
		}
		for ( var i = 0; i < params.length; ++i ) {
			if ( !this.params.get( params[ i ] ) ) {
				return false;
			}
		}
		return true;
	};

	function makeActionLink( action ) {
		const $actLink = $( '<a>' );

		if ( action.href ) {
			$actLink.attr( 'href', action.href );
		}

		if ( action.click ) {
			$actLink.on( 'click', action.click );
		}

		$actLink.append( action.text );

		if ( action.nopopup ) {
			$actLink.addClass( 'popups_nopopup' );
		}

		if ( action.class ) {
			// eslint-disable-next-line mediawiki/class-doc
			$actLink.addClass( action.class );
		}
		return $actLink;
	}

	function PopupManager() {
		this.popups = [];
	}

	PopupManager.prototype.destroyPopups = function ( fromIndex ) {

		fromIndex = fromIndex || 0;

		for ( let i = this.popups.length - 1; i >= fromIndex; --i ) {
			const leaf = this.popups.pop();
			leaf.$element.remove();
		}
	};

	PopupManager.prototype.addPopup = function ( popup ) {
		this.popups.push( popup );
	};

	PopupManager.prototype.showPopup = function ( $elem /* event */ ) {

		let $parent;

		// if we get some kind of relative URL, we need to make it relative
		// to the right thing
		let baseHref;

		if ( $elem.parents( '.popups_popup' ).length === 0 ) {
			// if this is a new root popup, remove ALL existing ones
			this.destroyPopups();
			$parent = $( document.body );

			baseHref = window.location.href;
		} else {
			// the closest popup to the hovered element
			$parent = $elem.closest( '.popups_popup' );

			const parentIndex = $parent.data( 'popups-popup-index' );

			// delete any would-be siblings and their descendents
			this.destroyPopups( parentIndex + 1 );

			baseHref = $parent.data( 'popups-base-href' );
		}

		// cached data that hooks can read and write
		var cache = new Cache();

		cache.link = new LinkCache(
			$elem.attr( 'href' ),
			baseHref
		);

		// update it in case it _wasn't_ relative
		baseHref = cache.link.url.href;

		var $popupContent = $( '<div>' ).addClass( 'popups_main' );
		var $popupTitle = $( '<div>' ).addClass( 'popups_title' );
		var $contentContainer = $( '<div>' ).addClass( 'popups_content' );
		var $actionsContainer = $( '<div>' ).addClass( 'popups_actions' );
		var $actionsList = $( '<ul>' );

		// $popupTitle.append(cache.pg_name);
		$actionsContainer.append( $actionsList );

		$popupContent
			.append( $popupTitle )
			.append( $actionsContainer )
			.append( $contentContainer );

		var popup = new OO.ui.PopupWidget( {
			$content: $popupContent,
			$floatableContainer: $elem,
			padded: false,
			width: null,
			autoClose: true,
			align: 'left',
			hideWhenOutOfView: false,
			classes: [ 'popups_popup' ]
		} );
		popup.$element.data( 'popups-base-href', baseHref );
		popup.$element.data( 'popups-popup-index', this.popups.length );
		$parent.append( popup.$element );

		var $throbber = $( '<div>' )
			.addClass( 'popups_throbber' )
			.appendTo( $contentContainer );

		popup.$element.on( 'keydown', function ( event ) {
			console.log( event );
			event.preventDefault();
		} );

		this.addPopup( popup );

		recognise( $elem, cache ).then( function ( data ) {
			// unpack
			const { recognised, score } = data;
			console.log( `Recognised with ${recognised.type}, score ${score}` );

			var $titleLink = $( '<a>' )
				.attr( 'href', recognised.href )
				.append( recognised.display )
				.appendTo( $popupTitle );

			if ( recognised.href === cache.link.href ||
				recognised.href === cache.link.href ) {
				$titleLink.addClass( 'popups_nopopup' );
			}

			$popupTitle
				.append(
					makeCopyIcon( recognised.canonical )
				);

			if ( recognised.editUrl ) {
				$popupTitle
					.append(
						makeEditIcon( recognised.editUrl )
					);
			}

			getContent( $elem, cache )
				.then( function ( $content, $shouldBeVisible ) {
					$throbber.remove();

					$contentContainer.append( $content );
					popup.toggle( true );

					if ( $shouldBeVisible && $shouldBeVisible.length > 0 ) {
						$shouldBeVisible[ 0 ].scrollElementIntoView();
					}
				} );

			getActions( $elem, cache, function ( actions ) {

				for ( var i = 0; i < actions.length; i++ ) {

					if ( actions[ i ].length === 0 ) {
						continue; // skip empties
					}

					var $list = $( '<li>' )
						.appendTo( $actionsList );

					if ( Array.isArray( actions[ i ] ) ) {

						for ( var j = 0; j < actions[ i ].length; ++j ) {

							if ( Array.isArray( actions[ i ][ j ] ) ) {
								$list.append( '(' );
								for ( var k = 0; k < actions[ i ][ j ].length; ++k ) {
									$list.append( makeActionLink( actions[ i ][ j ][ k ] ) );

									if ( k < actions[ i ][ j ].length - 1 ) {
										$list.append( ' | ' );
									}
								}
								$list.append( ')' );

							} else {
								var $actLink = makeActionLink( actions[ i ][ j ] );
								$list.append( $actLink );
							}

							if ( j < actions[ i ].length - 1 ) {
								$list.append( ' &middot; ' );
							}
						}

					} else {
						// single action
						var $singleActionLink = makeActionLink( actions[ i ] );
						$list.append( $singleActionLink );
					}
				}
				popup.toggle( true );
			} );
		} );
	};

	/**
	 * Set up popup handlers for a given element
	 *
	 * @param  {Object} elem the DOM element
	 */
	PopupManager.prototype.setupPopups = function ( elem ) {
		var that = this;
		$( elem ).find( 'a:not(.popups_nopopup)[href]' )
			// .css({"color": "red"})
			.on( 'mouseenter', function ( event ) {

				if ( event.altKey ) {
					return;
				}

				var $enteredElem = $( this );

				// $enteredElem.css({"color": "green"});

				setTimeout( function () {

					// popup if still hovering after 500ms
					if ( $enteredElem.is( ':hover' ) ) {
						that.showPopup( $enteredElem, event );
					}
				}, Popups.cfg.showTimeout );
			} );
	};

	const manager = new PopupManager();

	function onMutation( mutations ) {
		for ( var i = 0; i < mutations.length; i++ ) {
			for ( var j = 0; j < mutations[ i ].addedNodes.length; j++ ) {
				manager.setupPopups( mutations[ i ].addedNodes[ j ] );
			}
		}
	}

	function initPopupGadget() {

		if ( Popups.cfg.skinDenylist.indexOf( mw.config.get( 'skin' ) ) > -1 ) {
			return;
		}

		// cache user rights
		mw.user.getRights().then( function ( r ) {
			Popups.userRights = r;
		} );

		// eslint-disable-next-line no-jquery/no-global-selector
		var body = $( 'body' )[ 0 ];

		manager.setupPopups( body );

		var observer = new MutationObserver( onMutation );

		observer.observe( body, {
			subtree: true,
			childList: true
		} );
	}

	const loadables = [ $.ready ];

	mw.loader.using( [ 'mediawiki.api' ], function () {

		// cache user rights
		loadables.push( mw.loader.using( [ 'mediawiki.user' ],
			function () {
				mw.user.getRights()
					.then( function ( r ) {
						Popups.userRights = r;
					} );
			} )
		);

		const api = new Api( mw.config.get( 'wgServerName' ) );
		loadables.push( cacheSiteInfo( api ) );

		loadables.push( mw.loader.using( [
			'mediawiki.util', 'mediawiki.ForeignApi',
			'oojs-ui-core', 'oojs-ui-widgets'
		] ) );

		Promise.all( loadables ).then( () => {
			mw.hook( 'ext.gadget.popups-reloaded.config' ).fire( Popups.cfg );
			initPopupGadget();
		} );
	} );

// eslint-disable-next-line no-undef
}( jQuery, mediaWiki, Promise ) );