User:Daniel Quinlan/Scripts/AnyMessage.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.
'use strict';

mw.loader.using(['mediawiki.api', 'mediawiki.util', 'oojs-ui']).then(() => {
	if (mw.config.get('wgCanonicalSpecialPageName') !== 'Allmessages') return;
	const prefixInput = document.querySelector('input[name="prefix"]');
	const groupContainer = prefixInput?.closest('.oo-ui-fieldsetLayout-group');
	if (!groupContainer) return;

	// inject interface
	const prefixWidth = prefixInput.offsetWidth;
	const columnWidth = prefixWidth ? `${prefixWidth}px` : '50em';
	mw.util.addCSS(`
		.anymessage-grid {
			display: grid;
			grid-template-columns: minmax(max-content, ${columnWidth}) max-content;
			column-gap: 2em;
		}
		.anymessage-grid > .oo-ui-fieldLayout { grid-column: 1; }
		.anymessage-sidebar {
			grid-column: 2;
			grid-row: 1 / -1;
		}
		.anymessage-inline .oo-ui-checkboxMultioptionWidget {
			display: inline-block;
			margin-right: 1em;
		}
		#mw-content-text.anymessage .TablePager_nav,
		#mw-content-text.anymessage .mw-htmlform-field-HTMLSelectLimitField {
			display: none;
		}
	`);
	const api = new mw.Api();
	const typeSelect = new OO.ui.ButtonSelectWidget({
		items: [
			new OO.ui.ButtonOptionWidget({ data: 'prefix', label: 'Prefix', selected: true }),
			new OO.ui.ButtonOptionWidget({ data: 'string', label: 'String' }),
			new OO.ui.ButtonOptionWidget({ data: 'regex', label: 'Regex' })
		]
	});
	const caseCheckbox = new OO.ui.CheckboxInputWidget({ selected: true });
	const searchOptions = new OO.ui.CheckboxMultiselectWidget({
		items: [
			new OO.ui.CheckboxMultioptionWidget({ data: 'name', label: 'Name', selected: true }),
			new OO.ui.CheckboxMultioptionWidget({ data: 'text', label: 'Text', selected: true })
		],
		classes: ['anymessage-inline']
	});
	const sidebarFieldset = new OO.ui.FieldsetLayout({ classes: ['anymessage-sidebar'] });
	sidebarFieldset.addItems([
		new OO.ui.FieldLayout(typeSelect, { label: 'Match type:', align: 'top' }),
		new OO.ui.FieldLayout(caseCheckbox, { label: 'Case insensitive', align: 'inline' }),
		new OO.ui.FieldLayout(searchOptions, { label: 'Search:', align: 'inline' }),
	]);
	groupContainer.classList.add('anymessage-grid');
	groupContainer.append(sidebarFieldset.$element[0]);
	const labelElement = groupContainer.querySelector('.oo-ui-labelElement-label');
	if (labelElement) labelElement.textContent = 'Search:';

	// interface updates
	const updatePage = () => {
		const mode = typeSelect.findSelectedItem()?.getData();
		const isPrefix = mode === 'prefix';
		caseCheckbox.setDisabled(isPrefix);
		searchOptions.setDisabled(isPrefix);
		document.querySelector('#mw-content-text')?.classList.toggle('anymessage', !isPrefix);
	};
	typeSelect.on('select', updatePage);
	updatePage();

	// intercept submit
	prefixInput.closest('form')?.addEventListener('submit', async event => {
		if (typeSelect.findSelectedItem()?.getData() === 'prefix') return;
		event.preventDefault();
		const query = prefixInput.value.trim();
		const filter = document.querySelector('#mw-input-filter input[type="radio"]:checked')?.value || 'all';
		const language = document.querySelector('select[name="lang"]')?.value || mw.config.get('wgContentLanguage');
		await runSearch(query, filter, language);
	});

	async function runSearch(query, filter, language) {
		const table = document.getElementById('mw-allmessagestable');
		if (!table) return;
		const matchFn = buildMatcher(query);
		if (!matchFn) {
			showStatus(table, 'Invalid regular expression');
			return;
		}
		showStatus(table, 'Searching');
		try {
			const result = await api.get({
				action: 'query',
				meta: 'allmessages',
				amcustomised: filter,
				amlang: language,
				amprop: 'default'
			});
			const messages = result.query?.allmessages || [];
			const selectedData = searchOptions.findSelectedItemsData();
			const inName = selectedData.includes('name');
			const inText = selectedData.includes('text');
			const filtered = messages.filter(message => {
				if (!query) return true;
				if (inName && matchFn(message.name)) return true;
				if (inText) {
					if (matchFn(message['*'] || '')) return true;
					if ('default' in message && matchFn(message.default || '')) return true;
				}
				return false;
			});
			renderResults(table, filtered, language);
		} catch (error) {
			showStatus(table, `Search failed: ${error}`);
		}
	}

	function buildMatcher(query) {
		if (!query) return () => true;
		const mode = typeSelect.findSelectedItem()?.getData();
		const ignoreCase = caseCheckbox.isSelected();
		if (mode === 'regex') {
			try {
				const re = new RegExp(query, ignoreCase ? 'iu' : 'u');
				return string => re.test(string);
			} catch { return null; }
		}
		if (ignoreCase) {
			const lower = query.toLowerCase();
			return string => string.toLowerCase().includes(lower);
		}
		return string => string.includes(query);
	}

	function showStatus(table, text) {
		table.querySelectorAll('tbody').forEach(tb => tb.remove());
		const td = document.createElement('td');
		td.colSpan = 2;
		td.textContent = text;
		const tr = document.createElement('tr');
		tr.append(td);
		const tbody = document.createElement('tbody');
		tbody.append(tr);
		table.append(tbody);
	}

	function renderResults(table, messages, language) {
		table.querySelectorAll('tbody').forEach(tb => tb.remove());
		if (!messages.length) {
			showStatus(table, 'No results');
			return;
		}
		for (const message of messages) {
			table.append(renderMessage(message, language));
		}
		resolveTalkLinks(table);
	}

	function renderMessage(message, language) {
		const isModified = 'default' in message;
		const nameCell = buildNameCell(message, language, isModified ? 2 : 1);
		const cell1 = document.createElement('td');
		cell1.lang = language;
		cell1.dir = 'auto';
		cell1.textContent = isModified ? (message.default || '') : (message['*'] || '');
		if (isModified) cell1.classList.add('am_default');
		const tr1 = document.createElement('tr');
		tr1.append(nameCell, cell1);
		const tbody = document.createElement('tbody');
		tbody.append(tr1);
		if (isModified) {
			const cell2 = document.createElement('td');
			cell2.lang = language;
			cell2.dir = 'auto';
			cell2.textContent = message['*'] || '';
			cell2.classList.add('am_actual');
			const tr2 = document.createElement('tr');
			tr2.append(cell2);
			tbody.append(tr2);
		}
		return tbody;
	}

	function buildNameCell(message, language, rowspan) {
		const td = document.createElement('td');
		if (rowspan > 1) td.rowSpan = rowspan;
		const name = message.name;
		const isModified = 'default' in message;
		const editLink = document.createElement('a');
		editLink.textContent = name;
		editLink.href = mw.util.getUrl(`MediaWiki:${name}`, isModified ? {} : { action: 'edit', redlink: 1 });
		if (!isModified) editLink.className = 'new';
		const talkTitle = mw.Title.newFromText(name, 9)?.getPrefixedText() ?? null;
		const baseContent = (isModified ? message.default : message['*']) || '';
		let queryText = baseContent.trim();
		if (queryText.length > 1024) {
			const last = queryText.lastIndexOf(' ', 1024);
			queryText = last > 0 ? queryText.substring(0, last) : queryText.substring(0, 1024);
		}
		const twLink = document.createElement('a');
		twLink.className = 'external';
		twLink.rel = 'nofollow';
		twLink.textContent = 'Translate';
		twLink.href = `https://translatewiki.net/w/i.php?${new URLSearchParams({
			title: 'Special:SearchTranslations',
			group: 'mediawiki',
			grouppath: 'mediawiki',
			language: language,
			query: `${name} ${queryText}`.trim()
		})}`;
		if (talkTitle) {
			const talkLink = document.createElement('a');
			talkLink.className = 'new';
			talkLink.textContent = 'talk';
			talkLink.href = mw.util.getUrl(talkTitle, { action: 'edit', redlink: 1 });
			talkLink.dataset.anymessageTitle = talkTitle;
			td.append(editLink, ' (', talkLink, ') (', twLink, ')');
		} else {
			td.append(editLink, ' (', twLink, ')');
		}
		return td;
	}

	async function resolveTalkLinks(container) {
		const links = Array.from(container.querySelectorAll('a[data-anymessage-title]'));
		for (let i = 0; i < links.length; i += 50) {
			const batch = links.slice(i, i + 50);
			const result = await api.get({
				action: 'query',
				titles: batch.map(l => l.dataset.anymessageTitle),
				formatversion: 2
			});
			if (!result.query?.pages) continue;
			for (const page of result.query.pages) {
				if (!page.missing) {
					const link = batch.find(l => l.dataset.anymessageTitle === page.title);
					if (!link) continue;
					link.classList.remove('new');
					link.href = mw.util.getUrl(page.title);
					delete link.dataset.anymessageTitle;
				}
			}
		}
	}
});