User:Bugghost/Scripts/UserRoleIndicator.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the โ‡ง Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>
// Copied and edited from Novem Linguae's user highlighter simple: [[User:Novem_Linguae/Scripts/UserHighlighterSimple.js]]

class UserRoleIndicator {
	/**
	 * @param {jQuery} $ jquery
	 * @param {Object} mw mediawiki
	 * @param {Window} window
	 */
	constructor( $, mw, window ) {
		// eslint-disable-next-line no-jquery/variable-pattern
		this.$ = $;
		this.mw = mw;
		this.window = window;
	}

	async execute() {
		
		const defaultRoleInfoLookup = {
			wmf: ['๐ŸŒ', 'Wikimedia Foundation (WMF)'],
			bot: ['๐Ÿค–', 'Bot'],
			stewards: ['๐Ÿฉบ', 'Steward or Ombud'],
			arbcom: ['โš–๏ธ', 'Arbitration Committee member'],
			bureaucrats: ['๐Ÿ’ผโ€', 'Bureaucrat'],
			admins: ['๐Ÿงน', 'Admin'],
			formerAdmins: ['๐Ÿšฌ', 'Former Admin'],
			newPageReviewers: ['๐Ÿงบ', 'New page reviewer'],
			tenThousandEdits: ['๐Ÿ“š', 'More than 10,000 edits'],
			extendedConfirmed: ['๐Ÿ“˜', 'Extended confirmed'],
			lessThan500: ['๐Ÿฃ', 'Less than 500 edits'],
		};

		if(this.window.UserRoleIndicatorCustomLabels){
			this.roleInfoLookup = { ...defaultRoleInfoLookup, ...window.UserRoleIndicatorCustomLabels };
		}else{
			this.roleInfoLookup = defaultRoleInfoLookup;
		}

//		console.time("get usernames")
		await this.getUsernames();
//		console.timeEnd("get usernames")
		
		this.addCSS('user-role-indicator', 'font-size: smaller; display: inline; background: #b7b9ff55; padding: 0.1em; border-radius: 5px; margin-left: 3px;')

		const $links = this.$( '#article a, #bodyContent a, #mw_contentholder a' );

		// console.time("linkloop")
		$links.each( ( index, element ) => {
			this.$link = this.$( element );
			if ( !this.linksToAUser() ) {
				return;
			}
			this.user = this.getUserName();
			const isUserSubpage = this.user.includes( '/' );
			if ( isUserSubpage ) {
				return;
			}
			this.hasAdvancedPermissions = false;
			this.addRoleInfoIfNeeded();
		} );
		// console.timeEnd("linkloop")
	}

	addCSS( htmlClass, cssDeclaration ) {
		// .plainlinks is for Wikipedia Signpost articles
		// To support additional custom signature edge cases, add to the selectors here.
		this.mw.util.addCSS( `
			.plainlinks .${ htmlClass }.external,
			.${ htmlClass },
			.${ htmlClass } b,
			.${ htmlClass } big,
			.${ htmlClass } font,
			.${ htmlClass } kbd,
			.${ htmlClass } small,
			.${ htmlClass } span {
				${ cssDeclaration }
			}
		` );
	}

	async getWikitextFromCache( title ) {
		const api = new this.mw.ForeignApi( 'https://en.wikipedia.org/w/api.php' );
		let wikitext = '';
		await api.get( {
			action: 'query',
			prop: 'revisions',
			titles: title,
			rvslots: '*',
			rvprop: 'content',
			formatversion: '2',
			uselang: 'content', // needed for caching
			smaxage: '86400', // cache for 1 day
			maxage: '86400' // cache for 1 day
		} ).then( ( data ) => {
			wikitext = data.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
		} );
		return wikitext;
	}

	async getUsernames() {
		const dataString = await this.getWikitextFromCache( 'User:NovemBot/userlist.js' );
		const dataJSON = JSON.parse( dataString );

		this.wmf = {
			...dataJSON.founder,
			...dataJSON.boardOfTrustees,
			...dataJSON.staff
			// WMF is hard-coded a bit further down. The script detects those strings in the username. This is safe to do because the WMF string is blacklisted from names, so has to be specially created.
			// ...dataJSON['sysadmin'],
			// ...dataJSON['global-interface-editor'],
			// ...dataJSON['wmf-supportsafety'],
			// ...dataJSON['mediawikiPlusTwo'],
			// ...dataJSON['global-sysop'],
		};
		this.bot = dataJSON.bot;
		this.stewards = dataJSON.steward;
		this.arbcom = dataJSON.arbcom;
		this.bureaucrats = dataJSON.bureaucrat;
		this.admins = dataJSON.sysop;
		this.formerAdmins = dataJSON.formeradmin;
		this.newPageReviewers = dataJSON.patroller;
		this.tenThousandEdits = dataJSON[ '10k' ];
		this.extendedConfirmed = {
			...dataJSON.extendedconfirmed,
			...dataJSON.productiveIPs
		};
	}

	hasHref( url ) {
		return Boolean( url );
	}

	isAnchor( url ) {
		return url.charAt( 0 ) === '#';
	}

	isHttpOrHttps( url ) {
		return url.startsWith( 'http://', 0 ) ||
			url.startsWith( 'https://', 0 ) ||
			url.startsWith( '/', 0 );
	}

	/**
	 * Figure out the wikipedia article title of the link
	 *
	 * @param {string} url
	 * @param {mw.Uri} urlHelper
	 * @return {string}
	 */
	getTitle( url, urlHelper ) {
		// for links in the format /w/index.php?title=Blah
		const titleParameterOfUrl = this.mw.util.getParamValue( 'title', url );
		if ( titleParameterOfUrl ) {
			return titleParameterOfUrl;
		}

		// for links in the format /wiki/PageName. Slice off the /wiki/ (first 6 characters)
		if ( urlHelper.path.startsWith( '/wiki/' ) ) {
			return decodeURIComponent( urlHelper.path.slice( 6 ) );
		}

		return '';
	}

	notInUserOrUserTalkNamespace() {
		const namespace = this.titleHelper.getNamespaceId();
		const notInSpecialUserOrUserTalkNamespace = this.$.inArray( namespace, [ 2, 3 ] ) === -1;
		return notInSpecialUserOrUserTalkNamespace;
	}

	linksToAUser() {
		let url = this.$link.attr( 'href' );

		if ( !this.hasHref( url ) || this.isAnchor( url ) || !this.isHttpOrHttps( url ) ) {
			return false;
		}

		url = this.addDomainIfMissing( url );

		// mw.Uri(url) throws an error if it doesn't like the URL. An example of a URL it doesn't like is https://meta.wikimedia.org/wiki/Community_Wishlist_Survey_2022/Larger_suggestions#1%, which has a section link to a section titled 1% (one percent).
		let urlHelper;
		try {
			urlHelper = new this.mw.Uri( url );
		} catch {
			return false;
		}

		// Skip links that aren't to user pages
		const isUserPageLink = url.includes( '/w/index.php?title=User' ) || url.includes( '/wiki/User' );
		if ( !isUserPageLink ) {
			return false;
		}

		// Even if it is a link to a userpage, skip URLs that have any parameters except title=User, action=edit, and redlink=. We don't want links to diff pages, section editing pages, etc. to be highlighted.
		const urlParameters = urlHelper.query;
		delete urlParameters.title;
		delete urlParameters.action;
		delete urlParameters.redlink;
		const hasNonUserpageParametersInUrl = !this.$.isEmptyObject( urlParameters );
		if ( hasNonUserpageParametersInUrl ) {
			return false;
		}

		const title = this.getTitle( url, urlHelper );

		// Handle edge cases such as https://web.archive.org/web/20231105033559/https://en.wikipedia.org/wiki/User:SandyGeorgia/SampleIssue, which shows up as isUserPageLink = true but isn't really a user page.
		try {
			this.titleHelper = new this.mw.Title( title );
		} catch {
			return false;
		}

		if ( this.notInUserOrUserTalkNamespace() ) {
			return false;
		}

		const isDiscussionToolsSectionLink = url.includes( '#' );
		if ( isDiscussionToolsSectionLink ) {
			return false;
		}

		return true;
	}

	// Brandon Frohbieter, CC BY-SA 4.0, https://stackoverflow.com/a/4009771/3480193
	countInstances( string, word ) {
		return string.split( word ).length - 1;
	}

	/**
	 * mw.Uri(url) expects a complete URL. If we get something like /wiki/User:Test, convert it to https://en.wikipedia.org/wiki/User:Test. Without this, UserHighlighterSimple doesn't work on metawiki.
	 *
	 * @param {string} url
	 * @return {string} url
	 */
	addDomainIfMissing( url ) {
		if ( url.startsWith( '/' ) ) {
			url = window.location.origin + url;
		}
		return url;
	}

	/**
	 * @return {string}
	 */
	getUserName() {
		const user = this.titleHelper.getMain().replace( /_/g, ' ' );
		return user;
	}

	addRoleInfoIfAppropriate( listOfUsernames, label, descriptionForHover ) {
		if ( listOfUsernames[ this.user ] === 1 ) {
			this.addRoleIcon( label, descriptionForHover );
		}
	}

	addRoleIcon( icon, descriptionForHover ) {

		const title = this.$link.attr( 'title' );
		if ( !title || title.startsWith( 'User:' ) ) {
			this.$link.attr( 'title', descriptionForHover );
			this.$link.append($("<div class='user-role-indicator'>"+icon+"</div>"))
		}

		this.hasAdvancedPermissions = true;
	}

	addRoleInfoIfNeeded() {
		
		// highlight anybody with "WMF" in their name, case insensitive. this should not generate false positives because "WMF" is on the username blacklist. see https://meta.wikimedia.org/wiki/Title_blacklist
		if ( this.user.match( /^[^/]*WMF/i ) ) {
			this.addRoleIcon( this.roleInfoLookup.wmf[0], this.roleInfoLookup.wmf[1] );
		}

		// TODO: grab the order from an array, so I can keep checkForPermission and addCSS in the same order easily, lowering the risk of the HTML title="" being one thing, and the color being another
		this.addRoleInfoIfAppropriate( this.wmf, this.roleInfoLookup.wmf[0], this.roleInfoLookup.wmf[1]);
		this.addRoleInfoIfAppropriate( this.bot, this.roleInfoLookup.bot[0], this.roleInfoLookup.bot[1]);
		this.addRoleInfoIfAppropriate( this.stewards, this.roleInfoLookup.stewards[0], this.roleInfoLookup.stewards[1]);
		this.addRoleInfoIfAppropriate( this.arbcom, this.roleInfoLookup.arbcom[0], this.roleInfoLookup.arbcom[1]);
		this.addRoleInfoIfAppropriate( this.bureaucrats, this.roleInfoLookup.bureaucrats[0], this.roleInfoLookup.bureaucrats[1]);
		this.addRoleInfoIfAppropriate( this.admins, this.roleInfoLookup.admins[0], this.roleInfoLookup.admins[1]);
		this.addRoleInfoIfAppropriate( this.formerAdmins, this.roleInfoLookup.formerAdmins[0], this.roleInfoLookup.formerAdmins[1]);
		this.addRoleInfoIfAppropriate( this.newPageReviewers, this.roleInfoLookup.newPageReviewers[0], this.roleInfoLookup.newPageReviewers[1]);
		this.addRoleInfoIfAppropriate( this.tenThousandEdits, this.roleInfoLookup.tenThousandEdits[0], this.roleInfoLookup.tenThousandEdits[1]);
		this.addRoleInfoIfAppropriate( this.extendedConfirmed, this.roleInfoLookup.extendedConfirmed[0], this.roleInfoLookup.extendedConfirmed[1]);

		// If they have no perms, then they are non-EC, so <500 edits
		if ( !this.hasAdvancedPermissions ) {
			this.addRoleIcon(this.roleInfoLookup.lessThan500[0], this.roleInfoLookup.lessThan500[1]);
		}
	}
}

// Fire after wiki content is added to the DOM, such as when first loading a page, or when a gadget such as the XTools gadget loads.
mw.hook( 'wikipage.content' ).add( async () => {
	await mw.loader.using( [ 'mediawiki.util', 'mediawiki.Uri', 'mediawiki.Title' ], async () => {
		await ( new UserRoleIndicator( $, mw, window ) ).execute();
	} );
} );

// Fire after an edit is successfully saved via JavaScript, such as edits by the Visual Editor and HotCat.
mw.hook( 'postEdit' ).add( async () => {
	await mw.loader.using( [ 'mediawiki.util', 'mediawiki.Uri', 'mediawiki.Title' ], async () => {
		await ( new UserRoleIndicator( $, mw, window ) ).execute();
	} );
} );

// </nowiki>