/* global autosize */ ( function () { 'use strict'; /** * TranslateEditor Plugin * Prepare the translation editor UI for a translation unit (message). * This is mainly used with the messagetable plugin, * but it is independent of messagetable. * Example usage: * * $( 'div.messageRow' ).translateeditor( { * message: messageObject // Mandatory message object * } ); * * Assumptions: The jquery element to which translateeditor is applied will * internally contain the editor's generated UI. So it is going to have the same width * and inherited properies of the container. * The container can mark the message item with class 'message'. This is not * mandatory, but if found, when the editor is opened, the message item will be hidden * and the editor will appear as if the message is replaced by the editor. * See the UI of Translate messagetable for a demo. * * @param {HTMLElement} element * @param {Object} options * @param {Function} [options.beforeSave] Callback to call when translation is going to be saved. * @param {Function} [options.onReady] Callback to call when the editor is ready. * @param {Function} [options.onSave] Callback to call when translation has been saved. * @param {Function} [options.onSkip] Callback to call when a message is skipped. * @param {Object} options.message Object as returned by messagecollection api. * @param {TranslationApiStorage} [options.storage] */ function TranslateEditor( element, options ) { this.$editTrigger = $( element ); this.$editor = null; this.options = options; this.message = this.options.message; this.$messageItem = this.$editTrigger.find( '.message' ); this.shown = false; this.dirty = false; this.saving = false; this.expanded = false; this.listen(); this.storage = this.options.storage || new mw.translate.TranslationApiStorage(); this.canDelete = mw.translate.canDelete(); this.delayValidation = delayer(); } TranslateEditor.prototype = { /** * Initialize the plugin */ init: function () { // In case we have already created the editor earlier, // don't add a new one. The existing one may have unsaved // changes. if ( this.$editor ) { return; } this.render(); // onReady callback if ( this.options.onReady ) { this.options.onReady.call( this ); } }, /** * Render the editor UI */ render: function () { this.$editor = $( '
' ) .addClass( 'row tux-message-editor hide' ) .append( this.prepareEditorColumn(), this.prepareInfoColumn() ); this.expanded = false; this.$editTrigger.append( this.$editor ); if ( this.message.properties && this.message.properties.status === 'fuzzy' ) { this.addWarning( mw.message( 'tux-editor-outdated-warning' ).escaped(), 'fuzzy' ); } this.showTranslationHelpers(); }, /** * Mark the message as unsaved because of edits, can be resumed later * * @param {string} [highlightClass] Class for background highlighting */ markUnsaved: function ( highlightClass ) { var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' ); highlightClass = highlightClass || 'tux-highlight'; $tuxListStatus.children( '.tux-status-unsaved' ).remove(); $tuxListStatus.children().addClass( 'hide' ); $( '' ) .addClass( 'tux-status-unsaved ' + highlightClass ) .text( mw.msg( 'tux-status-unsaved' ) ) .appendTo( $tuxListStatus ); }, /** * Mark the message as unsaved because of saving failure. */ markUnsavedFailure: function () { this.markUnsaved( 'tux-warning' ); }, /** * Mark the message as no longer unsaved */ markUnunsaved: function () { var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' ); $tuxListStatus.children( '.tux-status-unsaved' ).remove(); $tuxListStatus.children().removeClass( 'hide' ); this.dirty = false; mw.translate.dirty = false; }, /** * Mark the message as being saved */ markSaving: function () { var $tuxListStatus = this.$editTrigger.find( '.tux-list-status' ); // Disable the save button this.$editor.find( '.tux-editor-save-button' ) .prop( 'disabled', true ); // Add a "Saving" indicator $tuxListStatus.empty(); $( '' ) .addClass( 'tux-status-unsaved' ) .text( mw.msg( 'tux-status-saving' ) ) .appendTo( $tuxListStatus ); }, /** * Mark the message as translated and successfully saved. */ markTranslated: function () { this.$editTrigger.find( '.tux-list-status' ) .empty() .append( $( '' ) .addClass( 'tux-status-translated' ) .text( mw.msg( 'tux-status-translated' ) ) ); this.$messageItem .removeClass( 'untranslated translated fuzzy proofread' ) .addClass( 'translated' ); this.dirty = false; if ( this.message.properties ) { $( '.tux-action-bar .tux-statsbar' ).trigger( 'change', [ 'translated', this.message.properties.status ] ); this.message.properties.status = 'translated'; // TODO: Update any other statsbar for the same group in the page. } }, /** * Save the translation */ save: function () { var translation, editSummary, translateEditor = this; mw.hook( 'mw.translate.editor.beforeSubmit' ).fire( translateEditor.$editor ); translation = translateEditor.$editor.find( '.editcolumn textarea' ).val(); editSummary = translateEditor.$editor.find( '.tux-input-editsummary' ).val() || ''; translateEditor.saving = true; // beforeSave callback if ( translateEditor.options.beforeSave ) { translateEditor.options.beforeSave( translation ); } // For responsiveness and efficiency, // immediately move to the next message. translateEditor.next(); // Now the message definitely has a history, // so make sure the history menu item is shown translateEditor.$editor.find( '.message-tools-history' ) .removeClass( 'hide' ); // Show the delete menu item if the user can delete if ( this.canDelete ) { translateEditor.$editor.find( '.message-tools-delete' ) .removeClass( 'hide' ); } this.storage.save( translateEditor.message.title, translation, editSummary ).done( function ( response, xhr ) { var editResp = response.edit; if ( editResp.result === 'Success' ) { translateEditor.message.translation = translation; translateEditor.onSaveSuccess(); // Handle errors } else if ( editResp.spamblacklist ) { // @todo Show exactly which blacklisted URL triggered it translateEditor.onSaveFail( mw.msg( 'spamprotectiontext' ) ); } else if ( editResp.info && editResp.info.indexOf( 'Hit AbuseFilter:' ) === 0 && editResp.warning ) { translateEditor.onSaveFail( editResp.warning ); } else { translateEditor.onSaveFail( mw.msg( 'tux-save-unknown-error' ) ); mw.log( response, xhr ); } } ).fail( function ( errorCode, response ) { translateEditor.onSaveFail( response.error && response.error.info || mw.msg( 'tux-save-unknown-error' ) ); if ( errorCode === 'assertuserfailed' ) { // eslint-disable-next-line no-alert alert( mw.msg( 'tux-session-expired' ) ); } } ); }, /** * Success handler for the translation saving. */ onSaveSuccess: function () { this.markTranslated(); this.$editTrigger.find( '.tux-list-translation' ) .text( this.message.translation ); this.saving = false; // remove warnings if any. this.removeWarning( 'diff' ); this.removeWarning( 'fuzzy' ); this.removeWarning( 'validation' ); this.$editor.find( '.tux-warning' ).empty(); this.$editor.find( '.tux-more-warnings' ) .addClass( 'hide' ) .empty(); $( '.tux-editor-clear-translated' ) .removeClass( 'hide' ) .prop( 'disabled', false ); this.$editor.find( '.tux-input-editsummary' ) .val( '' ) .prop( 'disabled', true ); // Save callback if ( this.options.onSave ) { this.options.onSave( this.message.translation ); } mw.translate.dirty = false; mw.hook( 'mw.translate.editor.afterSubmit' ).fire( this.$editor ); if ( mw.track ) { mw.track( 'ext.translate.event.translation', this.message ); } }, /** * Marks that there was a problem saving a translation. * * @param {string} error Strings of warnings to display. */ onSaveFail: function ( error ) { this.addWarning( mw.msg( 'tux-editor-save-failed', error ), 'translation-saving' ); this.saving = false; this.markUnsavedFailure(); }, /** * Skip the current message. * Record it to mark as hard. */ skip: function () { // @TODO devise good algorithm for identifying hard to translate messages }, /** * Jump to the next translation editor row. */ next: function () { var $next = this.$editTrigger.next( '.tux-message' ); // Skip if the message is hidden. For example in a filter result. if ( $next.length && $next.hasClass( 'hide' ) ) { this.$editTrigger = $next; this.next(); return; } // If this is the last message, just hide it if ( !$next.length ) { this.hide(); return; } $next.data( 'translateeditor' ).show(); // Scroll the page a little bit up, slowly. if ( $( document ).height() - ( $( window ).height() + window.pageYOffset + $next.height() ) > 0 ) { $( 'html, body' ).stop().animate( { scrollTop: $( '.tux-message-editor:visible' ).offset().top - 85 }, 500 ); } }, /** * Creates a menu element for the message tools. * * @param {string} className Used as the element's CSS class * @param {Object} query Used as the query in the mw.Uri object * @param {string} message The message of the label of the menu item * @return {jQuery} The new menu item element */ createMessageToolsItem: function ( className, query, message ) { var uri = new mw.Uri(); uri.path = mw.config.get( 'wgScript' ); uri.query = query; return $( '
  • ' ) .addClass( className ) .append( $( '' ) .attr( { href: uri.toString(), target: '_blank' } ) .text( mw.msg( message ) ) ); }, /** * Creates an element with a dropdown menu including * tools for the translators. * * @return {jQuery} The new message tools menu element */ createMessageTools: function () { var $editItem, $historyItem, $deleteItem, $translationsItem, $linkToThisItem; $editItem = this.createMessageToolsItem( 'message-tools-edit', { title: this.message.title, action: 'edit' }, 'tux-editor-message-tools-show-editor' ); if ( !mw.translate.canTranslate() ) { $editItem.addClass( 'hide' ); } $historyItem = this.createMessageToolsItem( 'message-tools-history', { title: this.message.title, action: 'history' }, 'tux-editor-message-tools-history' ); $deleteItem = this.createMessageToolsItem( 'message-tools-delete', { title: this.message.title, action: 'delete' }, 'tux-editor-message-tools-delete' ); // Hide these links if the translation doesn't actually exist. // They will be shown when a translation will be created. if ( this.message.translation === null ) { $historyItem.addClass( 'hide' ); $deleteItem.addClass( 'hide' ); } else if ( !this.canDelete ) { $deleteItem.addClass( 'hide' ); } // A link to Special:Translations, // with translations of this message to other languages $translationsItem = this.createMessageToolsItem( 'message-tools-translations', { title: 'Special:Translations', message: this.message.title }, 'tux-editor-message-tools-translations' ); $linkToThisItem = this.createMessageToolsItem( 'message-tools-linktothis', { title: 'Special:Translate', showMessage: this.message.key, group: this.message.primaryGroup }, 'tux-editor-message-tools-linktothis' ); return $( '
      ' ) .addClass( 'tux-dropdown-menu tux-message-tools-menu hide' ) .append( $editItem, $historyItem, $deleteItem, $translationsItem, $linkToThisItem ); }, prepareEditorColumn: function () { var translateEditor = this, sourceString, originalTranslation, $editorColumn, $messageKeyLabel, $moreWarningsTab, $warnings, $warningsBlock, $editAreaBlock, $textarea, $controlButtonBlock, $editingButtonBlock, $pasteOriginalButton, $editSummary, $editSummaryBlock, $discardChangesButton = $( [] ), $saveButton, $requestRight, $skipButton, $cancelButton, $sourceString, $closeIcon, $layoutActions, $infoToggleIcon, $messageList, targetLangAttrib, targetLangDir, targetLangCode, prefix, $messageTools = translateEditor.createMessageTools(), canTranslate = mw.translate.canTranslate(); $editorColumn = $( '
      ' ) .addClass( 'seven columns editcolumn' ); $messageKeyLabel = $( '
      ' ) .addClass( 'ten columns messagekey' ) .text( this.message.title ) .append( $( '' ).addClass( 'caret' ), $messageTools ) .on( 'click', function ( e ) { $messageTools.toggleClass( 'hide' ); e.stopPropagation(); } ); $closeIcon = $( '' ) .addClass( 'one column close' ) .attr( 'title', mw.msg( 'tux-editor-close-tooltip' ) ) .on( 'click', function ( e ) { translateEditor.hide(); e.stopPropagation(); } ); $infoToggleIcon = $( '' ) // Initially the editor column is contracted, // so show the expand button first .addClass( 'one column editor-info-toggle editor-expand' ) .attr( 'title', mw.msg( 'tux-editor-expand-tooltip' ) ) .on( 'click', function ( e ) { translateEditor.infoToggle( $( this ) ); e.stopPropagation(); } ); $layoutActions = $( '
      ' ) .addClass( 'two columns layout-actions' ) .append( $closeIcon, $infoToggleIcon ); $editorColumn.append( $( '
      ' ) .addClass( 'row tux-editor-titletools' ) .append( $messageKeyLabel, $layoutActions ) ); $messageList = $( '.tux-messagelist' ); originalTranslation = this.message.translation; sourceString = this.message.definition; $sourceString = $( '' ) .addClass( 'twelve columns sourcemessage' ) .attr( { lang: $messageList.data( 'sourcelangcode' ), dir: $messageList.data( 'sourcelangdir' ) } ) .text( sourceString ); // Adjust the font size for the message string based on the length if ( sourceString.length > 100 && sourceString.length < 200 ) { $sourceString.addClass( 'long' ); } if ( sourceString.length > 200 ) { $sourceString.addClass( 'longer' ); } $editorColumn.append( $( '
      ' ) .addClass( 'row' ) .append( $sourceString ) ); $warnings = $( '
      ' ) .addClass( 'tux-warning hide' ); $moreWarningsTab = $( '
      ' ) .addClass( 'tux-more-warnings hide' ) .on( 'click', function () { var $this = $( this ), $moreWarnings = $warnings.children(), lastWarningIndex = $moreWarnings.length - 1; // If the warning list is not open, only one warning is shown if ( $this.hasClass( 'open' ) ) { $moreWarnings.each( function ( index, element ) { // The first element must always be shown if ( index ) { $( element ).addClass( 'hide' ); } } ); $this .removeClass( 'open' ) .text( mw.msg( 'tux-warnings-more', lastWarningIndex ) ); } else { $moreWarnings.each( function ( index, element ) { // The first element must always be shown if ( index ) { $( element ).removeClass( 'hide' ); } } ); $this .addClass( 'open' ) .text( mw.msg( 'tux-warnings-hide' ) ); } } ); targetLangCode = $messageList.data( 'targetlangcode' ); if ( targetLangCode === mw.config.get( 'wgTranslateDocumentationLanguageCode' ) ) { targetLangAttrib = mw.config.get( 'wgContentLanguage' ); targetLangDir = $.uls.data.getDir( targetLangAttrib ); } else { targetLangAttrib = targetLangCode; targetLangDir = $messageList.data( 'targetlangdir' ); } $textarea = $( '