wikimedia/mediawiki-extensions-Translate

View on GitHub
resources/js/ext.translate.special.pagemigration.js

Summary

Maintainability
D
2 days
Test Coverage
( function () {
    'use strict';
    var noOfSourceUnits, noOfTranslationUnits,
        pageName = '',
        langCode = '',
        sourceUnits = [];

    /**
     * Create translation pages using content of right hand side blocks
     * and identifiers from left hand side blocks. Create pages only if
     * content is not empty.
     *
     * @param {number} i Array index to sourceUnits.
     * @param {string} content
     * @return {Function} Returns a function which returns a jQuery.Promise
     */
    function createTranslationPage( i, content ) {

        return function () {
            var api = new mw.Api();

            var identifier = sourceUnits[ i ].identifier;
            var title = 'Translations:' + pageName + '/' + identifier + '/' + langCode;
            var summary = $( '#pm-summary' ).val();

            return api.postWithToken( 'csrf', {
                action: 'edit',
                watchlist: 'nochange',
                title: title,
                text: content,
                summary: summary
            } );
        };
    }

    /**
     * Get the old translations of a given page at given time.
     *
     * @internal
     * @param {string} fuzzyTimestamp Timestamp in MediaWiki format
     * @param {string} pageTitle
     * @return {jQuery.Promise<Array>} Old translations
     */
    function splitTranslationPage( fuzzyTimestamp, pageTitle ) {
        var api = new mw.Api();

        return api.get( {
            action: 'query',
            prop: 'revisions',
            rvprop: 'content',
            rvstart: fuzzyTimestamp,
            rvlimit: 1,
            formatversion: '2',
            titles: pageTitle
        } ).then( function ( data ) {
            var $errorBox = $( '.mw-tpm-sp-error__message' );
            var obj = data.query.pages[ 0 ];
            // TODO: Handle other cases such as invalid page titles ie: obj.invalid
            if ( obj === undefined || obj.missing ) {
                $errorBox.text( mw.msg( 'pm-page-does-not-exist', pageTitle ) ).removeClass( 'hide' );
                return $.Deferred().reject();
            }
            if ( obj.revisions === undefined ) {
                // the case of /en subpage where first edit is by FuzzyBot
                $errorBox.text( mw.msg( 'pm-old-translations-missing', pageTitle ) ).removeClass( 'hide' );
                return $.Deferred().reject();
            }
            return obj.revisions[ 0 ].content.split( '\n\n' );
        } );
    }

    /**
     * Get the timestamp before FuzzyBot's first edit on page.
     *
     * @internal
     * @param {string} pageTitle
     * @return {jQuery.Promise<string>} Timestamp
     */
    function getFuzzyTimestamp( pageTitle ) {
        var api = new mw.Api();

        // This api call returns the timestamp of FuzzyBot's edit
        return api.get( {
            action: 'query',
            prop: 'revisions',
            rvprop: 'timestamp',
            rvuser: 'FuzzyBot',
            rvdir: 'newer',
            rvlimit: 1,
            formatversion: '2',
            titles: pageTitle
        } ).then( function ( data ) {
            var $errorBox = $( '.mw-tpm-sp-error__message' );
            var obj = data.query.pages[ 0 ];

            // Page does not exist if missing field is present
            if ( obj === undefined || obj.missing === '' ) {
                $errorBox.text( mw.msg( 'pm-page-does-not-exist', pageTitle ) ).removeClass( 'hide' );
                return $.Deferred().reject();
            }

            // Page exists, but no edit by FuzzyBot
            if ( obj.revisions === undefined ) {
                $errorBox.text( mw.msg( 'pm-old-translations-missing', pageTitle ) ).removeClass( 'hide' );
                return $.Deferred().reject();
            } else {
                // FB over here refers to FuzzyBot
                var timestampFB = obj.revisions[ 0 ].timestamp;
                var dateFB = new Date( timestampFB );
                dateFB.setSeconds( dateFB.getSeconds() - 1 );
                var timestampOld = dateFB.toISOString();
                mw.log( 'New Timestamp: ' + timestampOld );
                return timestampOld;
            }
        } );
    }

    /**
     * @typedef {Object} SourceUnit
     * @param {string} identifier
     * @param {string} definition
     */

    /**
     * Get the translation units created by Translate extension.
     *
     * @internal
     * @param {string} page Page name
     * @return {jQuery.Promise<SourceUnit[]>}
     */
    function getSourceUnits( page ) {
        var api = new mw.Api();

        return api.get( {
            action: 'query',
            list: 'messagecollection',
            mcgroup: 'page-' + page,
            mclanguage: 'en',
            mcprop: 'definition'
        } ).then( function ( data ) {
            sourceUnits = [];
            var result = data.query.messagecollection;
            for ( var i = 0; i < result.length; i++ ) {
                var sUnit = {};
                var key = result[ i ].key;
                sUnit.identifier = key.slice( key.lastIndexOf( '/' ) + 1 );
                sUnit.definition = result[ i ].definition;
                sourceUnits.push( sUnit );
            }
            return sourceUnits;
        } ).fail( function ( code, result ) {
            // Incase the group does not exist, just return an empty array.
            var $errorContainer = $( '.mw-tpm-sp-error__message' );
            var errorMessage = mw.msg( 'pm-translation-unit-fetch-failed' );
            if (
                code === 'badparameter' &&
                result.error && result.error.info.indexOf( 'mcgroup' ) !== -1
            ) {
                errorMessage = mw.msg( 'pm-pagetitle-not-translatable', page );
            }

            $errorContainer
                .text( errorMessage )
                .removeClass( 'hide' );
            $.Deferred().reject();
        } );
    }

    /**
     * Shift rows up by one unit. This is called after a unit is deleted.
     *
     * @param {jQuery} $start The starting node
     */
    function shiftRowsUp( $start ) {
        var $current = $start,
            $next = $start.next();

        while ( $next.length ) {
            var nextVal = $next.find( '.mw-tpm-sp-unit__target' ).val();
            $current.find( '.mw-tpm-sp-unit__target' ).val( nextVal );
            $current = $next;
            $next = $current.next();
        }
        if ( $current.find( '.mw-tpm-sp-unit__source' ).val() ) {
            $current.find( '.mw-tpm-sp-unit__target' ).val( '' );
        } else {
            $current.remove();
        }
    }

    /**
     * Shift rows down by one unit. This is called after a new empty unit is
     * added.
     *
     * @param {jQuery} $nextRow The next row to start with
     * @param {string} text The text of the next row
     * @return {string} text The text of the last row
     */
    function shiftRowsDown( $nextRow, text ) {
        while ( $nextRow.length ) {
            var oldText = $nextRow.find( '.mw-tpm-sp-unit__target' ).val();
            $nextRow.find( '.mw-tpm-sp-unit__target' ).val( text );
            $nextRow = $nextRow.next();
            text = oldText;
        }
        return text;
    }

    /**
     * Create a new row of source text and target text with action icons.
     *
     * @param {string} sourceText
     * @param {string} targetText
     * @return {jQuery} newUnit The new row unit object
     */

    function createNewUnit( sourceText, targetText ) {
        var $newUnit = $( '<div>' ).addClass( 'mw-tpm-sp-unit row' );
        var $sourceUnit = $( '<textarea>' ).addClass( 'mw-tpm-sp-unit__source five columns' )
            .prop( 'readonly', true ).attr( 'tabindex', '-1' ).val( sourceText );
        var $target = $( '<div>' ).addClass( 'five columns' );
        var $targetUnit = $( '<textarea>' ).addClass( 'mw-tpm-sp-unit__target' )
            .val( targetText ).prop( 'dir', $.uls.data.getDir( langCode ) );
        var $clearButton = $( '<button>' ).addClass( 'mw-tpm-sp-action--clear' )
            .attr( 'title', mw.msg( 'pm-clear-icon-hover-text' ) );

        $targetUnit.on( 'input', function () {
            var $input = $( this );
            if ( $input.val().length === 0 ) {
                $clearButton.addClass( 'hide' );
            } else {
                $clearButton.removeClass( 'hide' );
            }
        } ).trigger( 'input' );
        $target.append( $targetUnit, $clearButton );

        var $actionUnit = $( '<div>' ).addClass( 'mw-tpm-sp-unit__actions two columns' );
        $actionUnit.append(
            $( '<span>' ).addClass( 'mw-tpm-sp-action mw-tpm-sp-action--add' )
                .attr( 'title', mw.msg( 'pm-add-icon-hover-text' ) ),
            $( '<span>' ).addClass( 'mw-tpm-sp-action mw-tpm-sp-action--swap' )
                .attr( 'title', mw.msg( 'pm-swap-icon-hover-text' ) ),
            $( '<span>' ).addClass( 'mw-tpm-sp-action mw-tpm-sp-action--delete' )
                .attr( 'title', mw.msg( 'pm-delete-icon-hover-text' ) )
        );
        $newUnit.append( $sourceUnit, $target, $actionUnit );
        return $newUnit;
    }

    /**
     * Display the source and target units alongwith the action icons.
     *
     * @param {Array} units
     * @param {Array} translations
     */
    function displayUnits( units, translations ) {
        noOfSourceUnits = units.length;
        noOfTranslationUnits = translations.length;
        var totalUnits = noOfSourceUnits > noOfTranslationUnits ? noOfSourceUnits : noOfTranslationUnits;
        var $unitListing = $( '.mw-tpm-sp-unit-listing' );
        $unitListing.html( '' );
        for ( var i = 0; i < totalUnits; i++ ) {
            var sourceText = '', targetText = '';
            if ( units[ i ] !== undefined ) {
                sourceText = units[ i ].definition;
            }
            if ( translations[ i ] !== undefined ) {
                targetText = translations[ i ];
            }
            var $newUnit = createNewUnit( sourceText, targetText );
            $unitListing.append( $newUnit );
        }
    }

    /**
     * Split headers from remaining text in each translation unit if present.
     *
     * @internal
     * @param {Array} translations Array of initial units obtained on splitting
     * @return {string[]} Array having the headers split into new unit
     */
    function splitHeaders( translations ) {
        return translations.map( function ( elem ) {
            // Check https://regex101.com/r/oT7fZ2 for details
            return elem.match( /(^==.+$|(?:(?!^==).+\n?)+)/gm );
        } ).reduce( function ( acc, val ) {
            // This should be an Array.prototype.flatMap when ES2019 is supported
            return acc.concat( val );
        }, [] );
    }

    /**
     * Get the index of next translation unit containing h2 header.
     *
     * @param {number} startIndex Index to start the scan from
     * @param {string[]} translationUnits Segmented units.
     * @return {number} Index of the next unit found, -1 if not.
     */
    function getHeaderUnit( startIndex, translationUnits ) {
        var regex = new RegExp( /^==[^=]+==$/m );
        for ( var i = startIndex; i < translationUnits.length; i++ ) {
            if ( regex.test( translationUnits[ i ] ) ) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Align h2 headers in the order they appear.
     * Assumption: The source headers and translation headers appear in
     * the same order.
     *
     * @internal
     * @param {Object[]} units
     * @param {string[]} translationUnits
     * @return {string[]}
     */
    function alignHeaders( units, translationUnits ) {
        // The content does not have information about the page title. Add an empty string
        // at the beginning of the translationUnits array to match the length of units and
        // translationUnits.
        if ( units.length && units[ 0 ].identifier === 'Page_display_title' ) {
            translationUnits.unshift( '' );
        }
        var tIndex = 0;
        var regex = new RegExp( /^==[^=]+==$/m );
        for ( var i = 0; i < units.length; i++ ) {
            if ( regex.test( units[ i ].definition ) ) {
                tIndex = getHeaderUnit( tIndex, translationUnits );
                var mergeText = '';
                // search is over
                if ( tIndex === -1 ) {
                    break;
                }
                // remove the unit
                var matchText = translationUnits.splice( tIndex, 1 ).toString();
                var emptyCount = i - tIndex;
                if ( emptyCount > 0 ) {
                    // add empty units
                    while ( emptyCount !== 0 ) {
                        translationUnits.splice( tIndex, 0, '' );
                        emptyCount -= 1;
                    }
                } else if ( emptyCount < 0 ) {
                    // merge units until there is room for tIndex translation unit to
                    // align with ith source unit
                    while ( emptyCount !== 0 ) {
                        mergeText += translationUnits.splice( i, 1 ).toString() + '\n';
                        emptyCount += 1;
                    }
                    if ( i !== 0 ) {
                        translationUnits[ i - 1 ] += '\n' + mergeText;
                    } else {
                        matchText = mergeText + matchText;
                    }
                }
                // add the unit back
                translationUnits.splice( i, 0, matchText );
                tIndex = i + 1;
            }
        }
        return translationUnits;
    }

    /**
     * Handler for 'Save' button click event.
     */
    function saveHandler() {
        var list = [];

        $( '.mw-tpm-sp-error__message' ).addClass( 'hide' );
        if ( noOfSourceUnits < noOfTranslationUnits ) {
            $( '.mw-tpm-sp-error__message' ).text( mw.msg( 'pm-extra-units-warning' ) )
                .removeClass( 'hide' );
            return;
        } else {
            $( 'input' ).prop( 'disabled', true );
            $( '.mw-tpm-sp-instructions' ).addClass( 'hide' );
            for ( var i = 0; i < noOfSourceUnits; i++ ) {
                var content = $( '.mw-tpm-sp-unit__target' ).eq( i ).val();
                content = content.trim();
                if ( content !== '' ) {
                    list.push( createTranslationPage( i, content ) );
                }
            }

            $.ajaxDispatcher( list, 1 ).done( function () {
                $( '#action-import' ).removeClass( 'hide' );
                $( 'input' ).prop( 'disabled', false );
                $( '.mw-tpm-sp-instructions' )
                    .text( mw.msg( 'pm-on-save-message-text' ) )
                    .removeClass( 'hide' );
            } ).fail( function ( errmsg ) {
                $( 'input' ).prop( 'disabled', false );
                // eslint-disable-next-line mediawiki/msg-doc
                $( '.mw-tpm-sp-error__message' ).text( mw.msg( errmsg ) ).removeClass( 'hide' );
            } );
        }
    }

    /**
     * Handler for 'Cancel' button click event.
     */
    function cancelHandler() {
        $( '.mw-tpm-sp-error__message' ).addClass( 'hide' );
        $( '.mw-tpm-sp-instructions' ).addClass( 'hide' );
        $( '#action-save, #action-cancel' ).addClass( 'hide' );
        $( '#action-import' ).removeClass( 'hide' );
        $( '.mw-tpm-sp-unit-listing' ).html( '' );
    }

    /**
     * Handler for add new unit icon ('+') click event. Adds a translation unit
     * below the current unit.
     *
     * @param {jQuery.Event} event
     */
    function addHandler( event ) {
        var $nextRow = $( event.target ).closest( '.mw-tpm-sp-unit' ).next();
        var $targetUnit = $nextRow.find( '.mw-tpm-sp-unit__target' );
        var text = $targetUnit.val();
        $targetUnit.val( '' );
        $nextRow = $nextRow.next();
        text = shiftRowsDown( $nextRow, text );
        if ( text ) {
            var $newUnit = createNewUnit( '', text );
            $( '.mw-tpm-sp-unit-listing' ).append( $newUnit );
        }
        noOfTranslationUnits += 1;
    }

    /**
     * Handler for delete icon ('-') click event. Deletes the unit and shifts
     * the units up by one.
     *
     * @param {jQuery.Event} event
     */
    function deleteHandler( event ) {
        var $rowUnit = $( event.target ).closest( '.mw-tpm-sp-unit' );
        var sourceText = $rowUnit.find( '.mw-tpm-sp-unit__source' ).val();
        if ( !sourceText ) {
            $rowUnit.remove();
        } else {
            $rowUnit.find( '.mw-tpm-sp-unit__target' ).val( '' );
            shiftRowsUp( $rowUnit );
        }
        noOfTranslationUnits -= 1;
    }

    /**
     * Handler for clear icon click event. Clear the unit and maintains
     * the position of other units.
     *
     * @param {jQuery.Event} event
     */
    function clearContents( event ) {
        var $rowUnit = $( event.target ).closest( '.mw-tpm-sp-unit' );
        $rowUnit.find( '.mw-tpm-sp-unit__target' ).val( '' );
        $( event.target ).addClass( 'hide' );
    }

    /**
     * Handler for swap icon click event. Swaps the text in the current unit
     * with the text in the unit below.
     *
     * @param {jQuery.Event} event
     */
    function swapHandler( event ) {
        var $rowUnit = $( event.target ).closest( '.mw-tpm-sp-unit' );
        var tempText = $rowUnit.find( '.mw-tpm-sp-unit__target' ).val();
        var nextVal = $rowUnit.next().find( '.mw-tpm-sp-unit__target' ).val();
        $rowUnit.find( '.mw-tpm-sp-unit__target' ).val( nextVal );
        $rowUnit.next().find( '.mw-tpm-sp-unit__target' ).val( tempText );
    }

    /**
     * Handler for 'Import' button click event. Imports source and translation
     * units and displays them.
     *
     * @param {jQuery.Event} e
     */
    function importHandler( e ) {
        var $errorBox = $( '.mw-tpm-sp-error__message' ),
            $messageBox = $( '.mw-tpm-sp-instructions' );

        e.preventDefault();

        var pageTitle = $( '#title' ).val().trim();
        if ( pageTitle === '' ) {
            $errorBox.text( mw.msg( 'pm-pagetitle-missing' ) ).removeClass( 'hide' );
            return;
        }

        var titleObj = mw.Title.newFromText( pageTitle );
        $messageBox.addClass( 'hide' );
        if ( titleObj === null ) {
            $errorBox.text( mw.msg( 'pm-pagetitle-invalid' ) ).removeClass( 'hide' );
            return;
        }

        pageTitle = titleObj.getPrefixedDb();
        var slashPos = pageTitle.lastIndexOf( '/' );

        if ( slashPos === -1 ) {
            $errorBox.text( mw.msg( 'pm-langcode-missing' ) ).removeClass( 'hide' );
            return;
        }

        pageName = pageTitle.slice( 0, slashPos );
        langCode = pageTitle.slice( slashPos + 1 );

        if ( pageName === '' ) {
            $errorBox.text( mw.msg( 'pm-pagetitle-invalid' ) ).removeClass( 'hide' );
            return;
        }

        $errorBox.addClass( 'hide' );

        var fuzzyTimestamp = null;
        var units = null;
        getFuzzyTimestamp( pageTitle )
            .then( function ( response ) {
                fuzzyTimestamp = response;
                return getSourceUnits( pageName );
            } )
            .then( function ( response ) {
                units = response;
                return splitTranslationPage( fuzzyTimestamp, pageTitle );
            } )
            .then( function ( translations ) {
                noOfSourceUnits = units.length;
                var translationUnits = splitHeaders( translations );
                translationUnits = alignHeaders( units, translationUnits );
                noOfTranslationUnits = translationUnits.length;
                displayUnits( units, translationUnits );
                $( '#action-save, #action-cancel' ).removeClass( 'hide' );
                $( '#action-import' ).addClass( 'hide' );
                $messageBox.text( mw.msg( 'pm-on-import-message-text' ) ).removeClass( 'hide' );
            } );
    }

    /**
     * Listens to various click events
     */
    function listen() {
        var $listing = $( '.mw-tpm-sp-unit-listing' );

        $( '#mw-tpm-sp-primary-form' ).on( 'submit', importHandler );
        $( '#action-import' ).on( 'click', importHandler );
        $( '#action-save' ).on( 'click', saveHandler );
        $( '#action-cancel' ).on( 'click', cancelHandler );
        $listing.on( 'click', '.mw-tpm-sp-action--swap', swapHandler );
        $listing.on( 'click', '.mw-tpm-sp-action--delete', deleteHandler );
        $listing.on( 'click', '.mw-tpm-sp-action--add', addHandler );
        $listing.on( 'click', '.mw-tpm-sp-action--clear', clearContents );
    }

    $( listen );

    mw.translate = mw.translate || {};
    mw.translate = $.extend( mw.translate, {
        getSourceUnits: getSourceUnits,
        getFuzzyTimestamp: getFuzzyTimestamp,
        splitTranslationPage: splitTranslationPage,
        splitHeaders: splitHeaders,
        alignHeaders: alignHeaders
    } );

}() );