wikimedia/mediawiki-extensions-Translate

View on GitHub
resources/js/ext.translate.editor.helpers.js

Summary

Maintainability
F
3 days
Test Coverage
/*!
 * Translate editor additional helper functionality
 */
( function () {
    'use strict';

    function getEditSummaryTimeWithDiff( pageTitle, comment ) {
        var diffLink = mw.util.getUrl( pageTitle, {
            oldid: comment.revisionId,
            diff: 'prev'
        } );

        return $( '<a>' )
            .addClass( 'edit-summary-time' )
            .attr(
                {
                    href: diffLink,
                    target: '_blank'
                }
            )
            .data( 'commentTimestamp', comment.timestamp )
            .text( comment.humanTimestamp );
    }

    function getSpacer() {
        return '<span class="edit-summary-spacer">ยท</span>';
    }

    var translateEditorHelpers = {
        /** @internal */
        showDocumentationEditor: function () {
            var $infoColumnBlock = this.$editor.find( '.infocolumn-block' ),
                $editColumn = this.$editor.find( '.editcolumn' ),
                $messageDescEditor = $infoColumnBlock.find( '.message-desc-editor' ),
                $messageDescViewer = $infoColumnBlock.find( '.message-desc-viewer' );

            $infoColumnBlock
                .removeClass( 'five' )
                .addClass( 'seven' );
            $editColumn
                .removeClass( 'seven' )
                .addClass( 'five' );

            $messageDescViewer.addClass( 'hide' );

            $messageDescEditor.removeClass( 'hide' );
            $messageDescEditor.find( '.tux-textarea-documentation' ).trigger( 'focus' );

            // So that the link won't be followed
            return false;
        },

        /** @internal */
        hideDocumentationEditor: function () {
            var $infoColumnBlock = this.$editor.find( '.infocolumn-block' ),
                $editColumn = this.$editor.find( '.editcolumn' ),
                $messageDescEditor = $infoColumnBlock.find( '.message-desc-editor' ),
                $messageDescViewer = $infoColumnBlock.find( '.message-desc-viewer' );

            $infoColumnBlock
                .removeClass( 'seven' )
                .addClass( 'five' );
            $editColumn
                .removeClass( 'five' )
                .addClass( 'seven' );

            $messageDescEditor.addClass( 'hide' );
            $messageDescViewer.removeClass( 'hide' );
        },

        /**
         * Save the documentation
         *
         * @internal
         * @return {jQuery.Promise}
         */
        saveDocumentation: function () {
            var translateEditor = this,
                api = new mw.Api(),
                newDocumentation = translateEditor.$editor.find( '.tux-textarea-documentation' ).val();

            return api.postWithToken( 'csrf', {
                action: 'edit',
                title: translateEditor.message.title
                    .replace( /\/[a-z-]+$/, '/' + mw.config.get( 'wgTranslateDocumentationLanguageCode' ) ),
                text: newDocumentation
            } ).done( function ( response ) {
                var $messageDesc = translateEditor.$editor.find( '.infocolumn-block .message-desc' );

                if ( response.edit.result === 'Success' ) {
                    api.parse(
                        newDocumentation
                    ).done( function ( parsedDocumentation ) {
                        $messageDesc.html( parsedDocumentation );
                    } ).fail( function ( errorCode, results ) {
                        // Note: It is possible for results to be undefined.
                        var errorInfo = results && results.error ? results.error.info : 'No information';
                        $messageDesc.html( newDocumentation );
                        mw.log( 'Error parsing documentation ' + errorCode + ' ' + errorInfo );
                    } ).always( function () {
                        // A collapsible element etc. may have been added
                        mw.hook( 'wikipage.content' ).fire( $messageDesc );
                        translateEditor.hideDocumentationEditor();
                    } );
                } else {
                    mw.notify( 'Error saving message documentation' );
                    mw.log( 'Error saving documentation', response );
                }
            } ).fail( function ( errorCode, results ) {
                mw.notify( 'Error saving message documentation' );
                mw.log( 'Error saving documentation', errorCode, results );
            } );
        },

        /**
         * Shows the message documentation.
         *
         * @internal
         * @param {Object} documentation A documentation object as returned by API.
         */
        showMessageDocumentation: function ( documentation ) {
            if ( !mw.config.get( 'wgTranslateDocumentationLanguageCode' ) ) {
                return;
            }

            var $messageDescViewer = this.$editor.find( '.message-desc-viewer' );
            var $descEditLink = $messageDescViewer.find( '.message-desc-edit' );
            var $messageDoc = $messageDescViewer.find( '.message-desc' );

            // Display the documentation only if it's not empty and
            // documentation language is configured
            if ( documentation.error ) {
                // TODO: better error handling, especially since the presence of documentation
                // is heavily hinted at in the UI
                return;
            } else if ( documentation.value ) {
                var documentationDir = $.uls.data.getDir( documentation.language );

                // Show the documentation and set appropriate
                // lang and dir attributes.
                // The message documentation is assumed to be written
                // in the content language of the wiki.
                var langAttr = {
                    lang: documentation.language,
                    dir: documentationDir
                };

                // Possible classes:
                // * mw-content-ltr
                // * mw-content-rtl
                // (The direction classes are needed, because the documentation
                // is likely to be MediaWiki-formatted text.)
                $messageDoc
                    .attr( langAttr )
                    .addClass( 'mw-content-' + documentationDir )
                    .html( documentation.html );

                $messageDoc.find( 'a[href]' ).prop( 'target', '_blank' );

                this.$editor.find( '.tux-textarea-documentation' )
                    .attr( langAttr )
                    .val( documentation.value );

                $descEditLink.text( mw.msg( 'tux-editor-edit-desc' ) );

                if ( documentation.html.length > 500 ) {
                    var $readMore = $( '<span>' )
                        .addClass( 'read-more column' )
                        .text( mw.msg( 'tux-editor-message-desc-more' ) );

                    var expand = function () {
                        $messageDoc.removeClass( 'compact' );
                        $readMore.text( mw.msg( 'tux-editor-message-desc-less' ) );
                    };

                    var readMore = function () {
                        if ( $messageDoc.hasClass( 'compact' ) ) {
                            expand();
                        } else {
                            $messageDoc.addClass( 'compact' );
                            $readMore.text( mw.msg( 'tux-editor-message-desc-more' ) );
                        }
                    };

                    $readMore.on( 'click', readMore );

                    $messageDescViewer.find( '.message-desc-control' )
                        .prepend( $readMore );

                    $messageDoc.addClass( 'long compact' ).on( 'mouseenter mouseleave', expand );
                }

                // Enable dynamic content, such as collapsible elements
                mw.hook( 'wikipage.content' ).fire( $messageDoc );
            } else {
                $descEditLink.text( mw.msg( 'tux-editor-add-desc' ) );
            }

            $messageDescViewer.removeClass( 'hide' );
        },

        /**
         * Shows uneditable documentation.
         *
         * @internal
         * @param {Object} documentation A gettext object as returned by API.
         */
        showUneditableDocumentation: function ( documentation ) {
            if ( documentation.error ) {
                return;
            }

            var dir = $.uls.data.getDir( documentation.language );

            // The following classes are used here:
            // * mw-content-ltr
            // * mw-content-rtl
            this.$editor.find( '.uneditable-documentation' )
                .attr( {
                    lang: documentation.language,
                    dir: dir
                } )
                .addClass( 'mw-content-' + dir )
                .html( documentation.html )
                .removeClass( 'hide' );
        },

        /**
         * Shows the translations from other languages
         *
         * @internal
         * @param {Array} translations An inotherlanguages array as returned by the translation helpers API.
         */
        showAssistantLanguages: function ( translations ) {
            if ( translations.error ) {
                return;
            }

            if ( !translations.length ) {
                return;
            }

            var $elements = translations.map( function ( translation ) {
                var langAttr = {
                    lang: translation.language,
                    dir: $.uls.data.getDir( translation.language )
                };

                var $element = $( '<div>' )
                    .addClass( 'row in-other-language' )
                    .append(
                        $( '<div>' )
                            .addClass( 'nine columns suggestiontext' )
                            .attr( langAttr )
                            .text( translation.value ),
                        $( '<div>' )
                            .addClass( 'three columns language text-right' )
                            .attr( langAttr )
                            .text( $.uls.data.getAutonym( translation.language ) )
                    );

                this.suggestionAdder( $element, translation.value );

                return $element;
            }.bind( this ) );

            this.$editor.find( '.in-other-languages-title' )
                .removeClass( 'hide' )
                .after( $elements );
        },

        /**
         * Shows the translation suggestions from Translation Memory
         *
         * @internal
         * @param {Array} translations A ttmserver array as returned by API.
         */
        showTranslationMemory: function ( translations ) {
            if ( !translations.length ) {
                return;
            }

            // Container for the suggestions
            var $tmSuggestions = $( '<div>' ).addClass( 'tm-suggestions' );

            var $heading = this.$editor.find( '.tm-suggestions-title' );
            $heading.after( $tmSuggestions );

            var $messageList = $( '.tux-messagelist' );
            var lang = $messageList.data( 'targetlangcode' );
            var dir = $messageList.data( 'targetlangdir' );

            var suggestions = {};

            translations.forEach( function ( translation ) {
                // Remove once formatversion=2
                if ( translation.local === '' ) {
                    translation.local = true;
                } else if ( translation.local === undefined ) {
                    translation.local = false;
                }

                if ( translation.local && translation.location === this.message.title ) {
                    // Do not add self-suggestions
                    return;
                }

                // Check if suggestion with this value already exists
                var suggestion = suggestions[ translation.target ];
                if ( suggestion ) {
                    suggestion.count++;
                    suggestion.sources.push( translation );
                    suggestion.$showSourcesElement.children( 'a' ).text(
                        mw.msg(
                            'tux-editor-n-uses',
                            mw.language.convertNumber( suggestion.count )
                        )
                    );

                    return;
                }

                suggestion = {};

                suggestion.$showSourcesElement = $( '<div>' )
                    .addClass( 'text-right columns twelve' )
                    .append( $( '<a>' ).addClass( 'n-uses' ) );

                suggestion.$element = $( '<div>' )
                    .addClass( 'row tm-suggestion' )
                    .append(
                        $( '<div>' )
                            .addClass( 'nine columns suggestiontext' )
                            .attr( {
                                lang: lang,
                                dir: dir
                            } )
                            .text( translation.target ),
                        $( '<div>' )
                            .addClass( 'three columns quality text-right' )
                            .text(
                                mw.msg(
                                    'tux-editor-tm-match',
                                    mw.language.convertNumber( Math.floor( translation.quality * 100 ) )
                                )
                            ),
                        suggestion.$showSourcesElement
                    );

                suggestion.count = 1;
                suggestion.sources = [];
                suggestion.sources.push( translation );

                this.suggestionAdder( suggestion.$element, translation.target );

                suggestions[ translation.target ] = suggestion;
            }, this );

            if ( $.isEmptyObject( suggestions ) ) {
                return;
            }

            var currentSuggestionsOrder = [];
            Object.keys( suggestions ).forEach( function ( key ) {
                currentSuggestionsOrder.push( {
                    key: key,
                    count: suggestions[ key ].count,
                    quality: suggestions[ key ].sources[ 0 ].quality
                } );
            } );

            currentSuggestionsOrder.sort( function ( a, b ) {
                if ( a.quality === b.quality ) {
                    return b.count - a.count;
                }
                return a.quality < b.quality ? 1 : -1;
            } );

            currentSuggestionsOrder.forEach( function ( item ) {
                var currentSuggestion = suggestions[ item.key ];
                currentSuggestion.$showSourcesElement.on( 'click', function ( e ) {
                    this.onShowTranslationMemorySources( e, currentSuggestion );
                }.bind( this ) );
                $tmSuggestions.append( currentSuggestion.$element );
            }, this );

            $heading.removeClass( 'hide' );
        },

        /** @internal */
        onShowTranslationMemorySources: function ( e, suggestion ) {
            e.stopPropagation();

            if ( suggestion.$sourcesElement ) {
                suggestion.$sourcesElement.toggleClass( 'hide' );
                return;
            }

            // Build the sources list. Add class to show external icons :(
            suggestion.$sourcesElement = $( '<ul>' )
                .addClass( 'tux-tm-suggestion-source mw-parser-output' );

            // Sort local suggestions first, then alphabetically
            suggestion.sources.sort( function ( a, b ) {
                if ( a.local === b.local ) {
                    return a.location.localeCompare( b.location );
                } else {
                    return a.local ? -1 : 1;
                }
            } );

            suggestion.sources.forEach( function ( translation ) {
                suggestion.$sourcesElement.append(
                    $( '<li>' )
                        .append(
                            $( '<a>' )
                                .prop( 'target', '_blank' )
                                .prop( 'href', translation.editorUrl || translation.uri )
                                .text( translation.location )
                                .toggleClass( 'external', !translation.local )
                        )
                );
            } );
            suggestion.$element.after( suggestion.$sourcesElement );
        },

        /**
         * Shows the translation from machine translation systems
         *
         * @internal
         * @param {Array} suggestions
         */
        showMachineTranslations: function ( suggestions ) {
            if ( !suggestions.length ) {
                return;
            }

            var translateEditor = this;

            var $mtSuggestions = this.$editor.find( '.tm-suggestions' );

            if ( !$mtSuggestions.length ) {
                $mtSuggestions = $( '<div>' ).addClass( 'tm-suggestions' );
            }

            this.$editor.find( '.tm-suggestions-title' )
                .removeClass( 'hide' )
                .after( $mtSuggestions );

            var $messageList = $( '.tux-messagelist' );
            var translationLang = $messageList.data( 'targetlangcode' );
            var translationDir = $messageList.data( 'targetlangdir' );

            suggestions.forEach( function ( translation ) {
                var $translation;

                $translation = $( '<div>' )
                    .addClass( 'row tm-suggestion' )
                    .append(
                        $( '<div>' )
                            .addClass( 'nine columns suggestiontext' )
                            .attr( {
                                lang: translationLang,
                                dir: translationDir
                            } )
                            .text( translation.target ),
                        $( '<div>' )
                            .addClass( 'three columns text-right service' )
                            .text( translation.service )
                    );

                translateEditor.suggestionAdder( $translation, translation.target );

                $mtSuggestions.append( $translation );
            } );
        },

        /**
         * Makes the $source element clickable and clicking it will replace the
         * translation textarea with the given suggestion.
         *
         * @internal
         * @param {jQuery} $source
         * @param {string} suggestion Text to add
         */
        suggestionAdder: function ( $source, suggestion ) {
            var $target = this.$editor.find( '.tux-textarea-translation' );
            if ( $target.get( 0 ).readOnly ) {
                // If the textarea is disabled, then disable the translation aid.
                // Do not add the click handler.
                $source.addClass( 'tux-translation-aid-disabled' );
                return;
            }
            var inserter = function () {
                var selection;
                if ( window.getSelection ) {
                    selection = window.getSelection().toString();
                } else if ( document.selection && document.selection.type !== 'Control' ) {
                    selection = document.selection.createRange().text;
                }

                if ( !selection ) {
                    $target.val( suggestion ).trigger( 'focus' ).trigger( 'input' );
                }
            };

            $source.on( 'click', inserter )
                .addClass( 'shortcut-activated' );
        },

        /**
         * Shows the support options for the translator.
         *
         * @internal
         * @param {Object} support A support object as returned by API.
         */
        showSupportOptions: function ( support ) {
            // Support URL
            if ( support.url ) {
                this.$editor.find( '.help a' ).attr( 'href', support.url );
                this.$editor.find( '.help' ).removeClass( 'hide' );
            }
        },

        /**
         * Adds buttons for quickly inserting insertables.
         *
         * @internal
         * @param {Object} insertables A insertables object as returned by API.
         */
        addInsertables: function ( insertables ) {
            var count = insertables.length,
                $sourceMessage = this.$editor.find( '.sourcemessage' ),
                $buttonArea = this.$editor.find( '.tux-editor-insert-buttons' ),
                $textarea = this.$editor.find( '.tux-textarea-translation' );

            for ( var i = 0; i < count; i++ ) {
                // The dir and lang attributes must be set here,
                // because the language of the insertables is the language
                // of the source message and not of the translation.
                // The direction may appear confusing, for example,
                // in tvar strings, which would appear with the dollar sign
                // on the wrong end.
                $( '<button>' )
                    .prop( {
                        lang: $sourceMessage.prop( 'lang' ),
                        dir: $sourceMessage.prop( 'dir' )
                    } )
                    .addClass( 'insertable shortcut-activated' )
                    .text( insertables[ i ].display )
                    .data( 'iid', i )
                    .appendTo( $buttonArea );
            }

            $buttonArea.on( 'click', '.insertable', function () {
                var data = insertables[ $( this ).data( 'iid' ) ];
                if ( data.post === '' ) { // 1-piece insertables
                    $textarea.textSelection( 'replaceSelection', data.pre );
                } else {
                    $textarea.textSelection( 'encapsulateSelection', {
                        pre: data.pre,
                        post: data.post
                    } );
                }
                $textarea.trigger( 'focus' ).trigger( 'input' );
            } );

            this.resizeInsertables( $textarea );
        },

        /**
         * Loads and shows edit summaries
         *
         * @internal
         * @param {Array} editsummaries An array of edit summaries as returned by the API
         */
        showEditSummaries: function ( editsummaries ) {
            if ( !editsummaries.length ) {
                return;
            }

            var $editSummariesContainer = this.$editor.find( '.edit-summaries' );

            if ( !$editSummariesContainer.length ) {
                $editSummariesContainer = $( '<div>' ).addClass( 'edit-summaries' );
            }
            var $editSummariesTitle = this.$editor.find( '.edit-summaries-title' );
            $editSummariesTitle.after( $editSummariesContainer );
            var $summaryList = $( '<ul>' );
            var lastEmptySummaryCount = 0;
            var pageTitle = this.message.title;
            editsummaries.forEach( function ( comment ) {
                var $summaryListItem = $( '<li>' );
                // An additional tag is added so that display: list-item can be retained
                // for the <li> tag
                var $summaryItem = $( '<span>' );

                if ( comment.summary === '' ) {
                    var $lastSummaryItem = $summaryList.find( 'li' ).last();

                    // Last item added was an empty summary and the current one is also empty,
                    // so update that instead of adding a new one.
                    if ( $lastSummaryItem.hasClass( 'update-without-summary' ) ) {
                        $lastSummaryItem.find( 'span' ).text(
                            mw.msg(
                                'tux-editor-changes-without-summary',
                                mw.language.convertNumber( ++lastEmptySummaryCount )
                            )
                        );
                        // Remove the timestamp link if there is more than one empty summary.
                        $lastSummaryItem.find( '.edit-summary-time' ).remove();
                        // Remove the spacer since we no longer have a timestamp
                        $lastSummaryItem.find( '.edit-summary-spacer' ).remove();
                    } else {
                        // Add a new empty summary list item
                        $summaryItem.append(
                            $( '<span>' ).text(
                                mw.msg(
                                    'tux-editor-changes-without-summary',
                                    mw.language.convertNumber( ++lastEmptySummaryCount )
                                )
                            ),
                            getSpacer(),
                            getEditSummaryTimeWithDiff( pageTitle, comment )
                        );

                        $summaryList.append(
                            $summaryListItem
                                .addClass( 'update-without-summary' )
                                .append( $summaryItem )
                        );
                    }
                } else {
                    lastEmptySummaryCount = 0;
                    $summaryItem.append(
                        $( '<bdi>' )
                            .prop( 'lang', '' )
                            .addClass( 'edit-summary-message' )
                            .html( comment.summary ),
                        getSpacer(),
                        getEditSummaryTimeWithDiff( pageTitle, comment )
                    );

                    $summaryList.append( $summaryListItem.append( $summaryItem ) );
                }
            } );

            $editSummariesContainer.append( $summaryList );
            $editSummariesTitle.removeClass( 'hide' );
        },

        /** @internal */
        updateEditSummaryTimestamp: function () {
            // If the editor is hidden, don't bother updating anything or setting up another timeout
            if ( this.$editor.hasClass( 'hide' ) ) {
                return;
            }

            var $dateEntries = this.$editor.find( '.edit-summary-time' );
            // Edit summaries may not be loaded yet.
            // It is also possible that there are no summary or date entries.
            if ( $dateEntries.length !== 0 ) {
                // There are some date entries, load moment.js and update them.
                mw.loader.using( 'moment' ).done(
                    function () {
                        // Update the time for the edit summaries if a user leaves their
                        // browser open and comes back later.
                        $dateEntries.each( function () {
                            var $entry = $( this );
                            var timeago = moment
                                .utc( $entry.data( 'commentTimestamp' ), 'YYYYMMDDhhmmss' )
                                .fromNow();
                            $entry.text( timeago );
                        } );
                    }
                );
            }

            setTimeout( this.updateEditSummaryTimestamp.bind( this ), 20000 );
        },

        /**
         * Handles any necessary updates to translation helpers when an editor is reopened.
         *
         * @internal
         */
        updateTranslationHelpers: function () {
            this.updateEditSummaryTimestamp();
        },

        /**
         * Loads and shows the translation helpers.
         *
         * @internal
         */
        showTranslationHelpers: function () {
            // API call to get translation suggestions from other languages
            // callback should render suggestions to the editor's info column
            var api = new mw.Api();

            api.get( {
                action: 'translationaids',
                title: this.message.title,
                uselang: mw.config.get( 'wgUserLanguage' )
            } ).done( function ( result ) {
                this.$editor.find( '.infocolumn .loading' ).remove();

                if ( !result.helpers ) {
                    mw.log.warn( 'API did not return any translation helpers.' );
                    return false;
                }

                this.showMessageDocumentation( result.helpers.documentation );
                this.showUneditableDocumentation( result.helpers.gettext );
                this.showAssistantLanguages( result.helpers.inotherlanguages );
                this.showTranslationMemory( result.helpers.ttmserver );
                this.showMachineTranslations( result.helpers.mt );
                this.showSupportOptions( result.helpers.support );
                this.addDefinitionDiff( result.helpers.definitiondiff );
                this.addInsertables( result.helpers.insertables );
                this.showEditSummaries( result.helpers.editsummaries );

                // Load the possible warnings as soon as possible, do not wait
                // for the user to make changes. Otherwise users might try confirming
                // translations which fail checks. Confirmation seems to work but
                // the message will continue to appear outdated.
                if ( this.message.properties &&
                    this.message.properties.status === 'fuzzy'
                ) {
                    this.validateTranslation();
                }

                mw.hook( 'mw.translate.editor.showTranslationHelpers' ).fire(
                    result.helpers, this.$editor
                );

            }.bind( this ) ).fail( function ( errorCode, results ) {
                // results.error may be undefined
                var errorInfo = results && results.error && results.error.info || 'Unknown error';
                this.$editor.find( '.infocolumn .loading' ).remove();
                this.$editor.find( '.infocolumn' ).append(
                    $( '<div>' )
                        .text( mw.msg( 'tux-editor-loading-failed', errorInfo ) )
                        .addClass( 'mw-message-box-warning mw-message-box tux-translation-aid-error' )
                );
                mw.log.error( 'Error loading translation aids:', errorCode, results );
            }.bind( this ) );

            mw.hook( 'mw.translate.editor.afterEditorShown' ).add( function () {
                // Take care of updating any helpers when the editor is opened
                this.updateTranslationHelpers();
            }.bind( this ) );
        }
    };

    mw.translate = mw.translate || {};

    mw.translate = $.extend( mw.translate, {
        /**
         * Get the documentation edit URL for a title
         *
         * @param {string} title Message title with namespace
         * @return {string} URL for editing the documentation
         */
        getDocumentationEditURL: function ( title ) {
            return mw.util.getUrl(
                title + '/' + mw.config.get( 'wgTranslateDocumentationLanguageCode' ),
                { action: 'edit' }
            );
        }
    } );

    // Extend the translate editor
    mw.translate.editor = mw.translate.editor || {};
    $.extend( mw.translate.editor, translateEditorHelpers );

}() );