User:Daniel Quinlan/Scripts/UserHighlighterFast.js

//

class UserHighlighterFast { // data /** @type {Object} */ users;

// other variables /** @type {JQuery} */ $link; /** @type {string} */ user; /** @type {mw.Title} */ titleHelper; /** @type {Object} */ $;	/** @type {Object} */ mw; window;

/**	 * @param {Object} $ jquery * @param {Object} mw mediawiki */	constructor($, mw, window) { this.$ = $; this.mw = mw; this.window = window; this.userList = 'User:NovemBot/userlist.js'; this.hrefCache = new Map; }

async execute { await this.getUsernames; let that = this; this.$('#article a, #bodyContent a, #mw_contentholder a').each(function(index, element) {			that.$link = that.$(element);			that.addClassesToLinkIfNeeded;		}); }

async getLatestRevisionData(title) { try { const api = new this.mw.Api; const data = await api.get({				action: 'query',				prop: 'revisions',				titles: title,				rvprop: 'content|timestamp',				rvslots: 'main',				formatversion: '2'			});

const revision = data.query.pages[0].revisions[0]; const timestamp = Date.parse(revision.timestamp);

return { content: revision.slots.main.content, timestamp: timestamp }; } catch (error) { console.error('Error fetching data:', error); return { content: '', timestamp: 0 }; }	}

async getUsernames { try { const db = await new Promise((resolve, reject) => {				const request = indexedDB.open('usernamesDB', 1);

request.onerror = => reject('Failed to open database'); request.onsuccess = => resolve(request.result); request.onupgradeneeded = => { const db = request.result; db.createObjectStore('usernames'); };			});

const transaction = db.transaction('usernames', 'readonly'); const store = transaction.objectStore('usernames');

const cachedData = await new Promise((resolve, reject) => {				const request = store.get('usernamesData');				request.onerror = => reject('Error getting cached data');				request.onsuccess =  => resolve(request.result);			});

try { if (cachedData && cachedData.expiration > Date.now) { this.users = cachedData.users; return; // Use cached data and skip fetching }			} catch (error) { console.error('Error processing cached data:', error); }			console.log('Cached data not found or expired. Fetching from the server...'); const dataResult = await this.getLatestRevisionData(this.userList); const dataJSON = JSON.parse(dataResult.content);

let currentTime = Date.now; let expiration; if (Math.abs(currentTime - dataResult.timestamp) > 25 * 3600 * 1000) { expiration = currentTime + 24 * 3600 * 1000; console.log('Expiration is 24 hours from now:', expiration); } else { expiration = dataResult.timestamp + 25 * 3600 * 1000; console.log('Expiration is 25 hours from timestamp:', expiration); }

const mergedData = { expiration: expiration, users: { ...dataJSON['extendedconfirmed'], ...dataJSON['bot'], ...dataJSON['productiveIPs'], }			};

for (const key in dataJSON['sysop']) { mergedData.users[key] = 2; }

const writeTransaction = db.transaction('usernames', 'readwrite'); const writeStore = writeTransaction.objectStore('usernames');

writeStore.put(mergedData, 'usernamesData');

this.users = mergedData.users;

console.log('Data fetched from the server and cached successfully.'); } catch (error) { console.error('Error fetching usernames:', error); }	}

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 /wiki/PageName. Slice off the /wiki/ (first 6 characters) if ( urlHelper.path.startsWith('/wiki/') ) { return decodeURIComponent(urlHelper.path.slice(6)); }		// for links in the format /w/index.php?title=Blah let titleParameterOfURL = this.mw.util.getParamValue('title', url); if ( titleParameterOfURL ) { return titleParameterOfURL; }		return ''; }

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

linksToAUser(url) { if ( ! this.hasHREF(url) || this.isAnchor(url) || ! this.isHTTPorHTTPS(url) ) { return false; }

// Skip links that aren't to user pages if ( ! url.includes('/wiki/User') && ! url.includes('/w/index.php?title=User') && ! url.includes('/wiki/Special:Contributions') ) { 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). var urlHelper; try { urlHelper = new this.mw.Uri(url); } catch { 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. let urlParameters = urlHelper.query; delete urlParameters['title']; delete urlParameters['action']; delete urlParameters['redlink']; let hasNonUserpageParametersInUrl = ! this.$.isEmptyObject(urlParameters); if ( hasNonUserpageParametersInUrl ) { return false; }

let 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 looks like a user page, but isn't.		try { this.titleHelper = new this.mw.Title(title); } catch { return false; }

if ( this.isInvalidNamespace ) { return false; }

return true; }

/**	 * 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, UserHighlighterFast doesn't work on metawiki. */	addDomainIfMissing(url) { if ( url.startsWith('/') ) { url = window.location.origin + url; }		return url; }

/**	 * @return {string} */	getUserName { var user = this.titleHelper.getMain.replace(/_/g, ' '); if (user.startsWith('Contributions/')) { return user.slice(14); }		return user; }

addClassesToLinkIfNeeded { const href = this.$link.attr('href'); let result = this.hrefCache.get(href);

if (result === undefined && this.linksToAUser(href)) { result = false; this.user = this.getUserName; if (!this.user.includes('/')) { // Ensure user is not a subpage switch (this.users[this.user]) { case 2: result = 'uhf-administrator'; break; case 1: result = 'uhf-extended-confirmed'; break; }			}			this.hrefCache.set(href, result); }

if (result) { this.$link.addClass(result); }	} }

// TODO: race condition with xtools gadget. sometimes it fails to highlight the xtools gadget's username link // TODO: hook for after visual editor edit is saved? mw.hook('wikipage.content').add(async function {	await mw.loader.using(['mediawiki.util', 'mediawiki.Uri', 'mediawiki.Title'], async function { let uhf = new UserHighlighterFast($, mw, window); if (mw.config.get('wgNamespaceNumber') === 0 &&			mw.config.get('wgAction') === 'view' &&			this.$('.userlink').length == 0 &&			this.$('.mw-userlink').length == 0) { return; }		await uhf.execute; }); });

//