/**
* Edit check to detect issues with the tone of an edit, e.g. promotional or non-neutral language.
*
* @class
* @extends mw.editcheck.BaseEditCheck
*
* @constructor
* @param {mw.editcheck.Controller} controller
* @param {Object} [config]
* @param {number} [config.predictionThreshold=0.8] Threshold for the prediction value, between 0.5 and 1
* @param {boolean} [includeSuggestions=false]
*/
mw.editcheck.ToneCheck = function MWToneCheck() {
// Parent constructor
mw.editcheck.ToneCheck.super.apply( this, arguments );
// Bind with no arguments so it can be used as an event listener
this.showSuccessHandler = this.showSuccess.bind( this );
};
/* Inheritance */
OO.inheritClass( mw.editcheck.ToneCheck, mw.editcheck.AsyncTextCheck );
/* Static properties */
mw.editcheck.ToneCheck.static.defaultConfig = ve.extendObject( {}, mw.editcheck.ToneCheck.super.static.defaultConfig, {
showAsCheck: false,
predictionThreshold: 0.8,
ignoreQuotedContent: true
} );
mw.editcheck.ToneCheck.static.name = 'tone';
mw.editcheck.ToneCheck.static.allowedContentLanguages = [ 'en', 'es', 'fr', 'ja', 'pt' ];
mw.editcheck.ToneCheck.static.title = OO.ui.deferMsg( 'editcheck-tone-title' );
mw.editcheck.ToneCheck.static.description = ve.deferJQueryMsg( 'editcheck-tone-description' );
mw.editcheck.ToneCheck.static.footer = ve.deferJQueryMsg( 'editcheck-tone-footer' );
mw.editcheck.ToneCheck.static.success = OO.ui.deferMsg( 'editcheck-tone-thank' );
mw.editcheck.ToneCheck.static.choices = [
{
action: 'edit',
label: ve.msg( 'editcheck-dialog-action-revise' ),
modes: [ '' ]
},
{
action: 'recheck',
label: ve.msg( 'editcheck-dialog-action-recheck' ),
flags: [ 'primary', 'progressive' ],
icon: 'check',
modes: [ 'revising' ]
},
{
action: 'dismiss',
label: ve.msg( 'editcheck-dialog-action-decline' ),
modes: [ '', 'revising' ]
}
];
mw.editcheck.ToneCheck.static.queue = [];
/* Static methods */
/**
* Perform an asynchronous check
*
* @param {string} text The plaintext to check
* @return {Promise|any}
*/
mw.editcheck.ToneCheck.static.checkAsync = function ( text ) {
/* Don't send requests for short strings */
if ( text.trim().length <= 0 ) {
return false;
}
const deferred = ve.createDeferred();
// we don't need to think about deduplication because the memoization has
// handled that for us
this.queue.push( { text, deferred } );
this.doCheckRequestsDebounced();
return deferred.promise();
};
/**
* Make the actual API request, batching together all pending checks
*/
mw.editcheck.ToneCheck.static.doCheckRequests = function () {
const title = mw.Title.newFromText( mw.config.get( 'wgRelevantPageName' ) );
const titleText = title ? title.getMainText() : '';
// API will only accept at most 100 instances
const batchSize = 100;
while ( this.queue.length ) {
const subqueue = this.queue.splice( 0, batchSize );
mw.editcheck.fetchTimeout( 'https://api.wikimedia.org/service/lw/inference/v1/models/edit-check:predict', {
method: 'POST',
headers: {
'Content-Type': 'text/html'
},
body: JSON.stringify( { instances: subqueue.map( ( item ) => (
/* eslint-disable camelcase */
{
modified_text: item.text,
page_title: titleText,
original_text: '',
check_type: 'tone',
lang: mw.config.get( 'wgContentLanguage' )
}
/* eslint-enable camelcase */
) ) } )
} )
.then( ( response ) => response.json() )
.then( ( data ) => {
if ( data && data.predictions && subqueue.length === data.predictions.length ) {
subqueue.forEach( ( item, index ) => {
const prediction = data.predictions[ index ];
item.deferred.resolve( prediction );
} );
} else {
throw new Error( 'Invalid response from edit-check:predict' );
}
} )
.catch( ( error ) => {
subqueue.forEach( ( item ) => {
item.deferred.reject( error );
} );
} );
}
this.queue = [];
};
mw.editcheck.ToneCheck.static.doCheckRequestsDebounced = ve.debounce( mw.editcheck.ToneCheck.static.doCheckRequests, 1 );
/* Methods */
/**
* @inheritdoc
*/
mw.editcheck.ToneCheck.prototype.canBeShown = function ( ...args ) {
if ( !this.constructor.static.allowedContentLanguages.includes( mw.config.get( 'wgContentLanguage' ) ) ) {
return false;
}
return mw.editcheck.ToneCheck.super.prototype.canBeShown.call( this, ...args );
};
mw.editcheck.ToneCheck.prototype.afterMemoized = function ( data ) {
return !!( data.prediction && data.probability >= this.config.predictionThreshold );
};
mw.editcheck.ToneCheck.prototype.newAction = function ( fragment, outcome ) {
if ( !outcome ) {
return null;
}
// TODO: variant message/labels when in back-from-presave state
const action = new mw.editcheck.EditCheckAction( {
fragments: [ fragment ],
check: this
} );
action.on( 'stale', ( stale ) => {
action.setMode( stale ? 'revising' : '' );
action.gutterQuickAction = stale ? 'recheck' : null;
} );
return action;
};
mw.editcheck.ToneCheck.prototype.act = function ( choice, action, surface ) {
action.off( 'discard', this.showSuccessHandler );
// The 'interacted' tag was previously used for not showing the user
// the tone check again in pre-save if they had already interacted with it.
// Per T409991 this is no longer the behavior we want. Keeping the tag for future use.
this.tag( 'interacted', action );
if ( choice === 'dismiss' ) {
return action.widget.showFeedback( {
choices: [
{
data: 'appropriate',
label: ve.msg( 'editcheck-tone-reject-appropriate' )
},
{
data: 'uncertain',
label: ve.msg( 'editcheck-tone-reject-uncertain' )
},
{
data: 'other',
label: ve.msg( 'editcheck-tone-reject-other' )
}
]
} ).then( ( reason ) => {
this.dismiss( action );
this.showSuccess();
return ve.createDeferred().resolve( { action: choice, reason } ).promise();
} );
} else if ( choice === 'edit' && surface ) {
// Once revising has started the user will either make enough of an
// edit that this action is discarded, or will `act` again and this
// event-handler will be removed above:
// If in pre-save mode, close the check dialog
const closePromise = this.controller.inBeforeSave ? this.controller.closeDialog() : ve.createDeferred().resolve().promise();
return closePromise.then( () => {
const fragment = action.fragments[ action.fragments.length - 1 ].collapseToEnd();
// prevent triggering branch node change listeners and thus clearing staleness immediately:
this.controller.updateCurrentBranchNodeFromSelection( fragment.getSelection() );
// If we transitioned from the pre-save mode to mid-edit we need
// to switch to the new action-object. If we didn't, this will just
// find `action` again.
const newAction = this.controller.getActions().find( ( cAct ) => cAct.equals( action ) );
if ( newAction ) {
newAction.updateStale( true );
newAction.once( 'discard', newAction.check.showSuccessHandler );
// If we transitioned, this will result in us waiting until
// the sidebar is open:
this.controller.refresh( true ).then( () => {
this.controller.ensureActionIsShown( newAction );
// select won't have refocused the article if it didn't change:
surface.getView().focus();
} );
} else {
// This is unlikely, but if configuration means there's *not*
// an equivalent action in the mid-edit, go ahead and focus the fragment
fragment.select();
// select won't have refocused the article if it didn't change:
surface.getView().focus();
}
} );
} else if ( choice === 'recheck' ) {
const recheckDeferred = ve.createDeferred();
const progress = new OO.ui.ProgressBarWidget( {
progress: false,
inline: true
} );
action.widget.$body.prepend( progress.$element );
this.checkText( action.fragments[ action.fragments.length - 1 ].getText() )
.then( ( result ) => {
recheckDeferred.resolve( result );
} );
const minimumTimeDeferred = ve.createDeferred();
setTimeout( () => {
minimumTimeDeferred.resolve();
}, 500 );
setTimeout( () => {
/* Silently fail if it takes too long */
recheckDeferred.resolve();
}, 3000 );
action.tag( 'pending' );
// Caller requires a Deferred as it then calls '.always()'
// eslint-disable-next-line no-jquery/no-when
return $.when( recheckDeferred, minimumTimeDeferred ).then( ( result ) => {
action.updateStale( false );
action.untag( 'pending' );
progress.$element.remove();
if ( !result ) {
this.onSuccess( action );
}
} );
}
};
mw.editcheck.ToneCheck.prototype.onSuccess = function ( action ) {
this.showSuccess();
this.controller.removeAction( 'onBranchNodeChange', action, false );
};
/* Registration */
mw.editcheck.editCheckFactory.register( mw.editcheck.ToneCheck );