MediaWiki:Wp/isv/Common.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.
/*
 * Medžuviki transliterator
 * Converts visible content into Latin or Cyrillic
 * by [[User:Lev]] (originally at https://isv.miraheze.org/wiki/MediaWiki:Gadget-alphabet.js)
 *
 * Borrows from: https://www.mediawiki.org/wiki/MediaWiki:Gadget-Numerakri.js
 * @license <https://opensource.org/licenses/MIT>
 */
(function () {
	// MediaWiki config options
	var config = mw.config.get( [
		'wgAction',
		'wgContentLanguage',
		'wgNamespaceNumber',
		'wgPageContentModel',
		'wgUserName',
		'skin'
	] );

	if (
		// Only when viewing content
		config.wgAction !== 'view'
		// Any non-content pages
		|| config.wgPageContentModel !== 'wikitext'
		// Special, MediaWiki pages
		|| [ -1, 8 ].indexOf( config.wgNamespaceNumber ) > -1
	) return;
	
	// Variant indexes
	var varIndex = {
		default: 0,
		latn: 0,
		cyrl: 1
	};

	// Re-used variables
	var walker = null;
	var api;
	var targetStyle;
	var currentStyle = 'default';
	var defaultStorageKey = '__defaultAlphabetText';
	var ignoreClass = 'ext-gadget-alphabet-disable';
	var romanNumerals = [];

	// HTML tags that should not be touched by parser
	var skippedTags = [
		'code',
		'input',
		'link',
		'kbd',
		'noscript',
		'pre',
		'style',
		'textarea'
	];
	
	// Standard label text
	var varLabels = {
		'default': 'Lat./Кир.',
		'latn': 'Latinica',
		'cyrl': 'Кирилица'
	};
	
	/**
	 * Replacements (for Latin as a default).
	 * Syntax: ["Latin", "Cyrillic"],
	 * Put additional conversions of same letters after the main one.
	 */
	var data = {
		outliers: [
			// Use acutes for disambiguation purposes in Cyrillic
			[ 'lj', 'љ' ],
			// [ 'ĺj', 'лј' ],
			[ 'nj', 'њ' ],
			// [ 'ńj', 'нј' ]
		],
		mappings: [
			[ 'a', 'а' ],
			[ 'b', 'б' ],
			[ 'c', 'ц' ],
			[ 'č', 'ч' ],
			[ 'd', 'д' ],
			[ 'e', 'е' ],
			[ 'ě', 'є' ],
			[ 'f', 'ф' ],
			[ 'g', 'г' ],
			[ 'h', 'х' ],
			[ 'i', 'и' ],
			[ 'j', 'ј' ],
			[ 'k', 'к' ],
			[ 'l', 'л' ],
			[ 'ĺ', 'л' ],
			[ 'm', 'м' ],
			[ 'n', 'н' ],
			[ 'ń', 'н' ],
			[ 'o', 'о' ],
			[ 'p', 'п' ],
			[ 'r', 'р' ],
			[ 's', 'с' ],
			[ 'š', 'ш' ],
			[ 't', 'т' ],
			[ 'u', 'у' ],
			[ 'v', 'в' ],
			[ 'y', 'ы' ],
			[ 'z', 'з' ],
			[ 'ž', 'ж' ],
			
			// Optional (Extended) Interslavic, do not use in text preferably
			[ 'å', 'а' ],
			[ 'ć', 'ч' ],
			[ 'ď', 'д' ],
			[ 'đ', 'дж' ],
			[ 'ė', 'е' ],
			[ 'ę', 'е' ],
			[ 'ľ', 'љ' ],
			[ 'ň', 'н' ],
			[ 'ò', 'о' ],
			[ 'ŕ', 'р' ],
			[ 'ř', 'р' ],
			[ 'ś', 'с' ],
			[ 'ť', 'т' ],
			[ 'ų', 'у' ],
			[ 'ź', 'з' ],
			
			// Solely for Cyrillic to Latin conversions
			[ 'šč', 'щ' ],
			[ 'j', 'ь' ],
			[ 'ja', 'я' ],
			[ 'ju', 'ю' ],
			[ 'e', 'э' ],
			[ 'e', 'ѣ' ],
			
			// Non-used letters that can be good to have converted
			[ 'w', 'в' ],
			[ 'ł', 'л' ],
			[ 'ö', 'ӧ' ],
			[ 'ü', 'ӱ' ],
			[ 'x', 'кс' ]
		]
	};

	/**
	 * Filter the text nodes for tree walker.
	 *
	 * @param {HTMLElement|TextNode} node
	 * @return {number} NodeFilter.FILTER_* constant
	 */
	function filterNode( node ) {
		if ( node.nodeType === Node.TEXT_NODE ) {
			// Skip whitespace
			if ( !/\S/.test( node.nodeValue ) ) return NodeFilter.FILTER_REJECT;

			return NodeFilter.FILTER_ACCEPT;
		}

		// Skip this element and skip its children
		var tag = node.nodeName && node.nodeName.toLowerCase();
		if ( skippedTags.indexOf( tag ) > -1 ) return NodeFilter.FILTER_REJECT;

		var lang = $( node ).attr( 'lang' );
		var hasSkipClass = $( node ).hasClass( ignoreClass );
		if ( /*( lang && lang !== config.wgContentLanguage ) ||*/ hasSkipClass ) {
			return NodeFilter.FILTER_REJECT;
		}

		// Skip this element, but check its children
		return NodeFilter.FILTER_SKIP;
	}

	/**
	 * Replace all text in the filtered nodes.
	 *
	 * @param {TextNode} node
	 */
	function handleTextNode( node ) {
		if ( targetStyle === 'default' ) {
			restoreDefaults( node );
			return;
		}
		var original = node.nodeValue;
		var changed = original;

		changed = removeRomanNumerals( changed );
		changed = replaceText( changed );
		changed = fixRomanNumerals( changed );

		storeDefaultValue( node, original, changed );
		if ( original !== changed ) {
			node.nodeValue = changed;
		}
	}

	/**
	 * Restore defaults in all nodes (if possible).
	 *
	 * @param {TextNode} node
	 */
	function restoreDefaults( node ) {
		var defaults = node.parentNode[ defaultStorageKey ];
		var value = node.nodeValue;
		if ( typeof defaults !== 'object' || defaults === null ) {
			return;
		}

		if ( defaults[ value ] !== '' ) {
			node.nodeValue = defaults[ value ];
		}
	}

	/**
	 * Set defaults in the parent node.
	 * 
	 * @param {TextNode} node
	 * @param {string} original
	 * @param {string} changed
	 */
	function storeDefaultValue( node, original, changed ) {
		var parent = node.parentNode;
		if ( typeof parent[ defaultStorageKey ] !== 'object' || parent[ defaultStorageKey ] === null ) {
			parent[ defaultStorageKey ] = {};
		}

		if ( currentStyle === 'default' ) {
			parent[ defaultStorageKey ][ changed ] = original;
			return;
		}

		// Get default value from previous conversion
		if ( original in parent[ defaultStorageKey ] ) {
			parent[ defaultStorageKey ][ changed ] = parent[ defaultStorageKey ][ original ];
		}
	}

	/**
	 * Handling function for requestIdleCallback.
	 * See https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/mw-method-requestIdleCallback
	 */
	function idleWalker( deadline ) {
		var el;
		if ( !walker ) {
			return;
		}
		while ( deadline.timeRemaining() > 0 ) {
			el = walker.nextNode();
			if ( !el ) {
				// Reached the end
				walker = null;
				currentStyle = targetStyle;
				targetStyle = null;

				return;
			}
			handleTextNode( el );
		}

		// The user may interact with the page. We pause so the browser can process
		// interaction. The text handler will continue after that.
		if ( walker ) {
			mw.requestIdleCallback( idleWalker );
		}
	}
	
	/**
	 * Transliterate the content into one of the options.
	 *
	 * @param event The event or object: outputStyle ("cyrl", "latn"), trigger.
	 */
	function startPageConversion( event ) {
		if ( event.trigger !== true ) {
			event.preventDefault();
		}
		targetStyle = event.data.outputStyle;

		// Nothing to change, just show the default page
		if ( event.trigger !== true && currentStyle === targetStyle ) {
			return;
		}
		
		// Change selected tab and save variant
		var $targetTab = $( '#ca-varlang-' + targetStyle );
		$( '[id^="ca-varlang"].selected' ).removeClass( 'selected' );
		$( $targetTab ).addClass( 'selected' );
		$( '#p-variants-label > span' ).text( $targetTab.text() );
		if ( event.trigger !== true ) {
			setVariant( targetStyle );
		}
		
		if ( event.trigger === true && targetStyle === 'default' ) {
			return;
		}
	
		// If a walker is already active, replace it.
		// If no walker is active yet, start it.
		if ( !walker ) {
			mw.requestIdleCallback( idleWalker );
		}
		walker = document.createTreeWalker(
			document.querySelector( '#mw-content-text' ),
			NodeFilter.SHOW_ALL,
			filterNode,
			false
		);
		
		// Change interface language
		var lang = config.wgContentLanguage + ( targetStyle !== 'default' ? '-' + targetStyle : '' );
		document.querySelector( '#mw-content-text' ).setAttribute( 'lang', lang );
		document.documentElement.setAttribute( 'data-variant', lang );
	}

	/**
	 * Replace occurrences of a letter sequence
	 *
	 * @param {TextNode} str Text to do replacements in.
	 * @param {Array} data Replacement data.
	 * @param style Possible values: "default", "latn", "cyrl".
	 * @return {TextNode} Text with replacements.
	 */
	function replaceSequence( str, data, style ) {
		var input = data[ + !varIndex[ style ] ];
		var output = data[ varIndex[ style ] ];

		// Small function for uppercasing first letter only
		function capitalize( string ) {
			return string.charAt( 0 ).toUpperCase() + string.slice( 1 );
		}

		var capInput = capitalize( input );
		var capOutput = capitalize( output );
		var uppInput = input.toUpperCase();

		if ( !String.prototype.replaceAll ) {
			return str.replace( new RegExp( input, 'g' ), output )
				.replace( new RegExp( capInput, g ), capOutput )
				.replace( new RegExp( input.toUpperCase(), g ), capOutput );
		}

		return str.replaceAll( input, output )
			.replaceAll( capInput, capOutput )
			.replaceAll( uppInput, capOutput );
	}
	
	/**
	 * Replace text
	 * 
	 * @param {TextNode} str Text to do replacements in.
	 * @param style Possible values: "default", "latn", "cyrl".
	 */
	function replaceText( str, style ) {
		style = style || targetStyle;
		
		// Replace outliers first
		for ( var i = 0; i < data.outliers.length; i++ ) {
			str = replaceSequence( str, data.outliers[ i ], style );
		}

		// Replace the letters
		for ( var i = 0; i < data.mappings.length; i++ ) {
			str = replaceSequence( str, data.mappings[ i ], style );
		}
		
		return str;
	}

	/**
	 * Remove Roman numerals from the script
	 * See https://phabricator.wikimedia.org/source/mediawiki/browse/master/includes/language/converters/ShConverter.php$132
	 *
	 * @param {TextNode} str Text to do replacements in.
	 */
	function removeRomanNumerals( str ) {
		romanNumerals = [];
		var romanRegex = /\b(?=[MDCLXVI])M{0,4}(C[DM]|D?C{0,3})(X[LC]|L?X{0,3})(I[VX]|V?I{0,3})\b/g;
		return str.replace( romanRegex, function( match, $1 ) {
			if ( match.length <= 1 ) {
				return match;
			}
			var count = romanNumerals.length;
			// Also in fixRomanNumerals()
			var id = '----' + count + '----';

			romanNumerals.push( match );
			return id;
		} );
	}

	/**
	 * Fix Roman numerals previously removed by the script
	 * 
	 * @param {TextNode} str Text to do replacements in.
	 */
	function fixRomanNumerals( str ) {
		var idRegex = /----([\d+])----/g;
		return str.replace( idRegex, function( match, $1 ) {
			var numeral = romanNumerals[ $1 ];
			return numeral !== null ? numeral : match;
		} );
	}
	
	/**
	 * Read user option / local storage for variant.
	 * 
	 * @return {string} Value from option / local storage or "default".
	 */
	function getVariant() {
		var value;
		if ( config.wgUserName === null ) {
			mw.loader.using( 'mediawiki.storage' ).done( function() {
				value = mw.storage.get( 'ext-gadget-alphabet' );
				value = ( value !== null ? value : 'default' );
			} );

			return value;
		}

		mw.loader.using( 'mediawiki.user' ).done( function() {
			value = mw.user.options.get( 'userjs-ext-gadget-alphabet' );
			value = ( value !== null ? value : 'default' );
		} );
		return value;
	}
	
	/**
	 * Set user option / cookie for variant.
	 * 
	 * @param name Possible values: "default", "latn", "cyrl".
	 */
	function setVariant( name, prev ) {
		var isDefault = ( name === 'default' );
		var message = 'Vaše prědpočitańje azbuky bylo ' + ( isDefault ? 'udaljeno.' : 'zapisano.' );
		if ( currentStyle === 'cyrl' || name === 'cyrl' ) {
			message = replaceText( message, 'cyrl' );
		}
		if ( config.wgUserName === null ) {
			mw.loader.using( 'mediawiki.storage' ).done( function() {
				var action = ( isDefault ? 'remove' : 'set' );

				var stored = mw.storage[ action ]( 'ext-gadget-alphabet', name );
				if ( stored ) mw.notify( message );
			} );

			return;
		}

		mw.loader.using( [ 'mediawiki.api', 'mediawiki.user' ] ).done( function() {
			if ( !api ) api = new mw.Api();
			var value = ( isDefault ? null : name );
			
			api.saveOption( 'userjs-ext-gadget-alphabet', value ).then( function() {
				mw.notify( message );
			} );
		} );
	}
	
	/**
	 * Add alphabet variants and start initial conversion.
	 */
	function init() {
		var isVector = ( config.skin === 'vector' || config.skin === 'vector-2022' );
		var isMinerva = config.skin === 'minerva';
		if ( isVector ) {
			$( '#p-variants' ).removeClass( 'emptyPortlet' );
			$( '#p-variants-label' ).addClass( ignoreClass );
		}

		if ( isMinerva ) {
			// Hacky way to add tabs in Minerva
			var $wrapper = $( '<div style="float:right;">' );
			var $minervaPortletLink = $( 'a[rel="discussion"]' ).clone()
				.attr( 'href', '/wiki/Wp/isv/Vikipedija:Pomoč/Transliteracija' )
				.removeClass( 'new' )
				.removeAttr( 'rel' ).removeAttr( 'data-event-name' );
			$( '.minerva__tab-container' ).append( $wrapper );
		}
		
		function addPortletLink( code ) {
			var $link;
			if ( isMinerva ) {
				$link = $minervaPortletLink.clone()
					.attr( 'id', 'ca-varlang-' + code )
					.text( varLabels[ code ] );

				if ( code === 'default' ) $link.addClass( 'selected' );

				$wrapper.append( $link );
			} else {
				var $portlet = $( mw.util.addPortletLink(
					( isVector ? 'p-variants' : 'p-cactions' ),
					'/wiki/Wp/isv/Vikipedija:Pomoč/Transliteracija',
					varLabels[ code ],
					'ca-varlang-' + code
				) );

				var lang = config.wgContentLanguage + ( code !== 'default' ? '-' + code : '' );
				if ( code === 'default' ) $portlet.addClass( 'selected' );
				$portlet.attr( 'lang', lang ).addClass( ignoreClass );
				$link = $portlet.find( 'a' );
			}

			$link.click( { 'outputStyle': code }, startPageConversion );
		}
		
		mw.loader.using( 'mediawiki.util' ).done( function() {
			addPortletLink( 'default' );
			addPortletLink( 'latn' );
			addPortletLink( 'cyrl' );
		} );

		// Start conversion when the document is idle
		var variant = getVariant();
		$( '#p-variants-label > span' ).text( varLabels[ variant ] );
		mw.requestIdleCallback( function() {
			startPageConversion( {
				data: {
					outputStyle: variant
				},
				trigger: true
			} );
		} );
	}

	$( init );
}() );