resources/js/ext.translate.editor.helpers.js
/*!
* 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 );
}() );