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
);
}
}
} );