User:Stjn/translatorBuddy.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/*
 * translatorBuddy.js / [[Translator Buddy]]
 * 1. Adds a ‘All translations (translatewiki.net)’ link in ‘Tools’ menu for MediaWiki namespaces
 *
 * 2. Adds a ‘Translate (translatewiki.net)’ page tab for non-local pages in MediaWiki namespace
 *
 * 3. Adds a ?uselang=qqx link to the footer for pages in other namespaces
 *
 * 4. Extends ?uselang=qqx debugging with popups with links to translatewiki.net/local messages
 * (disable with &tbdisable=1 or link in footer)
 *
 * Full doc: https://www.mediawiki.org/wiki/Translator_Buddy
 * Source: https://www.mediawiki.org/wiki/User:Stjn/translatorBuddy.js
*/
mw.loader.using( [
	'mediawiki.api',
	'mediawiki.util',
], () => {
	// MediaWiki config
	const _c = mw.config.get( [
		'skin',
		'wgArticleId',
		'wgContentLanguage',
		'wgNamespaceNumber',
		'wgPageContentLanguage',
		'wgPageContentModel',
		'wgPageName',
		'wgServer',
		'wgScript',
		'wgTitle',
		'wgUserGroups',
		'wgUserLanguage',
	] );
	
	const _disableLinkId = 'translatorBuddy-linkTbdisable';
	
	const _notTranslatewiki = !_c.wgServer.includes( 'translatewiki.net' );
	
	// Check if user has interface access
	const _hasInterfaceAccess = [
		'sysop',
		'interface-admin',
		'engineer', // for ru.wikipedia.org
	].some( x => _c.wgUserGroups.includes( x ) );
	
	// Ignored page prefixes in MediaWiki: namespace
	const _ignoredPagePrefixes = [
		'Gadget-',
		'Gadgets-definition',
	];
	
	// Ignored selectors list
	const _ignoredSelectors = [
		'html',
		'body',
		'#content',
		'#bodyContent',
		'.mw-body-content > .mw-parser-output',
	];
	
	// Localisable tools
	const _toolGroups = {
		'Convenient-discussions-': 'Wikimedia',
	};
	const _toolGroupKeys = Object.keys( _toolGroups );
	
	const modifyCurrentUrl = ( key, val ) => {
		const url = new URL( window.location.href );
		url.searchParams.set( key || 'uselang', val || 'qqx' );
		return url;
	};
	
	const getTwLabel = ( str ) => {
		return str
			+ mw.msg( 'word-separator' )
			+ mw.msg( 'parentheses', 'translatewiki.net' );
	};
	
	const getTwLink = ( page, query ) => {
		let url = mw.util.getUrl( page, query );
		return `https://translatewiki.net${ url.replace( _c.wgScript, '/w/i.php' ) }`;
	};
	
	const noSubpage = ( page, lang ) => page.replace( new RegExp( `\\/${ lang }$` ), '' );
	
	// Guess namespace depending on the group
	const guessNamespace = ( page ) => {
		const tool = _toolGroupKeys.find( g => page.startsWith( g ) );
		if ( tool ) {
			return `${ _toolGroups[ tool ] }:${ page }`;
		}
		
		return `MediaWiki:${ page }`;
	};
	
	// Link o edit page to translatewiki.net (except for /en)
	const getTwEditLink = ( page ) => {
		let lang = _c.wgPageContentLanguage;
		let query = {
			action: 'edit',
		};
		if ( lang === 'qqx' ) {
			lang = _c.wgContentLanguage;
		}
		if ( lang === 'en' ) {
			query = null;
		}
		return getTwLink( `${ noSubpage( page, lang ) }/${ lang }`, query );
	};
	
	// Link to edit page or view page depending on user groups
	const getLocalEditLink = ( page ) => {
		return mw.util.getUrl( page ) + ( _hasInterfaceAccess ? '?action=edit' : '' );
	};
	
	// All unique messages from a string
	const getAttrMessages = ( str ) => {
		if ( !str || str.trim() === '' ) {
			return [];
		}
		
		const messageRegExp = /\(([^:\),=]*?)\s*[:\),]/g;
		const matches = str.match( messageRegExp );
		if ( matches === null ) {
			return [];
		}
		
		let splits = [];
		return matches.map( m => {
			let result = m.replace( messageRegExp, '$1' );
			
			// Match can contain multiple messages split by /, don’t ask why
			const separator = ' / ';
			if ( !m.includes( separator ) ) {
				return result;
			}
			
			splits.push( ...result.split( separator ) );
			return null;
		} ).filter( m => m ).concat( splits );
	};
	
	// Return all message IDs related to the current node
	const getMessages = ( node, origin ) => {
		let result = [];
		const tagName = node.tagName ? node.tagName.toLowerCase() : '';
		
		if ( tagName === '' || node.nodeName === '#text' ) {
			return [];
		}
		
		if ( _ignoredSelectors.some( sel => node.matches( sel ) ) ) {
			return [];
		}
		
		// Check aria-labelledby="" value and check its node
		const ariaLabelledBy = node.getAttribute( 'aria-labelledby' );
		if ( ariaLabelledBy ) {
			const ariaLabel = document.getElementById( ariaLabelledBy );
			if ( ariaLabel !== null ) {
				result.push( ...getAttrMessages( ariaLabel.innerText ) );
			}
		}
		
		// Check form-specific values
		const formElements = [ 'button', 'input', 'option', 'select', 'textarea' ];
		if ( formElements.includes( tagName ) ) {
			if ( origin !== 'label' && node.id ) {
				const forLabel = document.querySelector( `label[for="${ node.id }"]` );
				if ( forLabel !== null ) {
					result.push( ...getMessages( forLabel, tagName ) );
				}
			}
			result = [
				...result,
				...getAttrMessages( node.getAttribute( 'placeholder' ) ),
				...getAttrMessages( node.getAttribute( 'value' ) ),
				...getAttrMessages( node.value ),
			];
		}
		
		// Check for="" value for labels and check its node
		if ( !formElements.includes( origin ) && tagName === 'label' ) {
			const forField = document.getElementById( node.getAttribute( 'for' ) );
			if ( forField !== null ) {
				result.push( ...getMessages( forField, tagName ) );
			}
		}
		
		// Check first child for select
		if ( origin !== 'select' && tagName === 'select' ) {
			const firstChild = node.firstElementChild;
			if ( firstChild !== null ) {
				result.push( ...getMessages( firstChild, tagName ) );
			}
		}
		
		return [
			...result,
			...getAttrMessages( node.innerText ),
			...getAttrMessages( node.getAttribute( 'title' ) ),
			...getAttrMessages( node.getAttribute( 'aria-label' ) ),
			...getAttrMessages( node.getAttribute( 'accesskey' ) ),
		];
	};
	
	const getMessageList = ( msgs ) => {
		let $result = $( '<div>' );
		
		if ( msgs.length === 0 ) {
			$result.text( '(empty)' );
			return $result;
		}
		
		$result.append( '<b>MediaWiki:</b>' );
		
		let $list = $( '<ul>' );
		const capitalise = val => ( String( val ).charAt( 0 ).toUpperCase() + String( val ).slice( 1 ) );
		msgs.forEach( val => {
			val = capitalise( val );
			const title = guessNamespace( val );
			const $item = $( '<li>' );
			
			const $link = $( '<a>' );
			$link.text( val )
				.attr( 'href', getTwEditLink( title ) )
				.addClass( 'translatorBuddy-twLink' );
			$item.append( $link );
			
			// Account for Wikimedia: and other namespaces
			if ( _notTranslatewiki && title.startsWith( 'MediaWiki:' ) ) {
				const $localLink = $( '<a>' );
				$localLink.text( '(local)' )
					.attr( 'href', getLocalEditLink( title ) )
					.addClass( 'translatorBuddy-localLink' );
				
				$item.append( ' ' ).append( $localLink );
			}
			
			$list.append( $item );
		} );
		
		$result.append( $list );
		return $result;
	};
	
	// Show popup on clicks for elements with message keys
	let msgPopup = null;
	const handleMessageClick = ( e ) => {
		if ( e.shiftKey ) {
			return true;
		}
		
		const target = e.target;
		if ( target.closest( `#${ _disableLinkId }` ) !== null ) {
			return true;
		}
		
		// Ignore links in popup itself
		if ( target.closest( '.translatorBuddy-popup' ) !== null ) {
			return true;
		}
		
		e.preventDefault();
		if ( msgPopup !== null ) {
			msgPopup.toggle( false );
		}
		
		const filterArrayDuplicates = ( val, i, arr ) => arr.indexOf( val ) === i;
		const msgs = [
			...getMessages( target ),
			...getMessages( target.parentNode )
		].filter( filterArrayDuplicates );

		// Borrowing from https://en.wikipedia.org/wiki/User:BrandonXLF/ShowTemplates.js
		mw.loader.using( 'oojs-ui', () => {
			if ( msgPopup === null ) {
				msgPopup = new OO.ui.PopupWidget( {
					classes: [ 'translatorBuddy-popup' ],
					padded: true,
					position: 'below',
					align: 'force-right',
				} );
				
				$( document.body ).append( msgPopup.$element );
			}
			
			msgPopup.$body.empty().append( getMessageList( msgs ) );
			
			const rect = target.getBoundingClientRect();
			msgPopup.setFloatableContainer(
				$('<div>').css( {
					position: 'absolute',
					left: ( window.scrollX + rect.left + rect.width / 4 ) + 'px',
					top: ( window.scrollY + rect.top + rect.height ) + 'px',
				} ).appendTo( document.body )
			);
			msgPopup.toggle( true );
		} );
	};

	// Add links to Tools (‘All translations’) and page tabs (‘Translate’)
	if ( _notTranslatewiki && [ 8, 9 ].includes( _c.wgNamespaceNumber ) ) {
		new mw.Api().loadMessagesIfMissing( [
			'allmessages-filter-translate',
			'edit',
			'parentheses',
			'translations',
			'view',
			'word-separator',
		] ).then( () => {
			if ( _ignoredPagePrefixes.some( p => _c.wgTitle.startsWith( p ) ) ) {
				return;
			}
			
			mw.util.addPortletLink(
				'p-tb',
				getTwLink( `Special:Translations/${ noSubpage( _c.wgPageName, _c.wgPageContentLanguage ) }` ),
				getTwLabel( mw.msg( 'translations' ) ),
				'translatorBuddy-linkAll'
			);
			
			// Add a link/tab to current page on translatewiki.net
			const noLocalPage = _c.wgArticleId === 0;
			const tabPlacements = {
				'minerva': 'p-tb',
				'timeless': 'p-views',
				'vector': 'p-views',
				'vector-2022': 'p-views',
			};
			if ( _c.wgNamespaceNumber === 8 && _c.wgPageContentModel === 'wikitext' ) {
				let translateLabel = noLocalPage ? 'allmessages-filter-translate' : 'edit';
				if ( _c.wgPageContentLanguage === 'en' ) {
					translateLabel = 'view';
				}
				mw.util.addPortletLink(
					noLocalPage ? ( tabPlacements[ _c.skin ] || 'p-cactions' ) : 'p-cactions',
					getTwEditLink( _c.wgPageName ),
					getTwLabel( mw.msg( translateLabel ) ),
					'translatorBuddy-linkTranslate',
					null, null,
					'#ca-move'
				);
			}
		} );
	} else if ( _c.wgUserLanguage !== 'qqx' ) {
		let link = mw.util.addPortletLink(
			'footer-places',
			modifyCurrentUrl(),
			'uselang=qqx',
			'translatorBuddy-linkUselang'
		);
		
		link.querySelector( 'a' ).addEventListener( 'click', ( e ) => {
			e.preventDefault();
			
			window.location = modifyCurrentUrl();
		} );
	}
	
	// React to clicks on elements on ?uselang=qqx
	if ( _c.wgUserLanguage === 'qqx' ) {
		mw.util.addCSS( `
			html > body .translatorBuddy-popup.translatorBuddy-popup { display: block; z-index: 99999 }
			.translatorBuddy-twLink { font-style: italic }
		` );
		
		if ( !window.location.search.includes( 'tbdisable=1' ) ) {
			document.addEventListener( 'click', handleMessageClick );
			document.addEventListener( 'contextmenu', handleMessageClick );
			
			mw.util.addPortletLink(
				'footer-places',
				modifyCurrentUrl( 'tbdisable', '1' ),
				'uselang=qqx [tbdisable]',
				_disableLinkId
			);
		}
	}
} );