mw.editcheck.TextMatchEditCheck = function MWTextMatchEditCheck() {
// Parent constructor
mw.editcheck.TextMatchEditCheck.super.apply( this, arguments );
this.lang = mw.config.get( 'wgContentLanguage' );
this.sensitivity = 'accent'; // TODO figure out how to determine this on an editcheck level
this.collator = new Intl.Collator( this.lang, { sensitivity: this.sensitivity } );
const rawMatchItems = Object.assign(
{},
this.constructor.static.matchItems || {},
this.config.matchItems || {}
);
this.matchItems = [];
this.matchItemsById = new Map();
// Create matchItem instances
Object.entries( rawMatchItems ).forEach( ( [ id, item ] ) => {
const textMatchItem = new mw.editcheck.TextMatchItem( item, id, this.collator );
this.matchItems.push( textMatchItem );
this.matchItemsById.set( id, textMatchItem );
} );
// Initialize lookup maps
this.matchItemsSensitiveByTerm = {};
this.matchItemsInsensitiveByTerm = {};
this.matchItems.forEach( ( matchItem ) => {
if ( !matchItem.expand && matchItem.config.minOccurrences ) {
mw.log.warn( 'MatchItem \'' + matchItem.title + '\' sets minOccurrences but is missing expand value.' );
}
const targetMap = matchItem.isCaseSensitive() ?
this.matchItemsSensitiveByTerm :
this.matchItemsInsensitiveByTerm;
Object.keys( matchItem.query ).forEach( ( key ) => {
if ( !targetMap[ key ] ) {
targetMap[ key ] = [];
}
targetMap[ key ].push( matchItem );
} );
} );
};
OO.inheritClass( mw.editcheck.TextMatchEditCheck, mw.editcheck.BaseEditCheck );
mw.editcheck.TextMatchEditCheck.static.name = 'textMatch';
/**
* The configs of TextMatchEditCheck take priority over individual matchItem configs.
* So we make TextMatch’s defaults nonrestrictive,
* and let the finer limitations be handled by individual matchItems.
*/
mw.editcheck.TextMatchEditCheck.static.defaultConfig = ve.extendObject( {}, mw.editcheck.BaseEditCheck.static.defaultConfig, {
maximumEditcount: null,
minimumEditcount: null
} );
mw.editcheck.TextMatchEditCheck.static.choices = [
{
action: 'dismiss',
label: OO.ui.deferMsg( 'ooui-dialog-process-dismiss' ),
modes: [ '', 'info', 'replace', 'delete' ]
},
{
action: 'accept',
label: OO.ui.deferMsg( 'ooui-dialog-message-accept' ),
modes: [ 'replace' ]
},
{
action: 'delete',
label: OO.ui.deferMsg( 'visualeditor-contextitemwidget-label-remove' ),
modes: [ 'delete' ]
}
];
mw.editcheck.TextMatchEditCheck.static.matchItems = [];
/**
* Given a term, find all the equivalent keys that exist in case-insensitive matchItem queries
*
* @param {string} term Term to find keys for
* @return {string} Array of keys that match
*/
mw.editcheck.TextMatchEditCheck.prototype.getMatchingKeys = function ( term ) {
const matches = Object.keys( this.matchItemsInsensitiveByTerm ).filter(
( key ) => this.collator.compare( key, term ) === 0
);
return matches;
};
mw.editcheck.TextMatchEditCheck.prototype.handleListener = function ( surfaceModel, listener ) {
const actions = [];
const fragmentCountsByItem = new Map();
const document = surfaceModel.getDocument();
const modified = this.getModifiedContentRanges( document );
const matchConfigs = [
{
caseSensitive: true,
terms: Object.keys( this.matchItemsSensitiveByTerm ),
lookup: ( term ) => this.matchItemsSensitiveByTerm[ term ] || [ ]
},
{
caseSensitive: false,
terms: Object.keys( this.matchItemsInsensitiveByTerm ),
lookup: ( term ) => {
const keys = this.getMatchingKeys( term );
return keys
.map( ( key ) => this.matchItemsInsensitiveByTerm[ key ] || [] )
.reduce( ( acc, arr ) => acc.concat( arr ), [] );
}
}
];
for ( const { caseSensitive, terms, lookup } of matchConfigs ) {
const ranges = document.findText(
new Set( terms ),
{
caseSensitiveString: caseSensitive,
wholeWord: true
}
);
for ( const range of ranges ) {
if ( !modified.some( ( modRange ) => range.touchesRange( modRange ) ) ) {
continue;
}
if ( !this.isRangeInValidSection( range, surfaceModel.documentModel ) ) {
continue;
}
const term = surfaceModel.getLinearFragment( range ).getText();
const relevantMatchItems = lookup( term );
if ( !relevantMatchItems ) {
continue;
}
for ( const matchItem of relevantMatchItems ) {
const name = this.getTagNameByMatchItem( matchItem, term );
if ( this.isDismissedRange( range, name ) ) {
continue;
}
if ( matchItem.listener && matchItem.listener !== listener ) {
continue;
}
if ( !this.constructor.static.doesConfigMatch( matchItem.config, surfaceModel.documentModel ) ) {
continue;
}
let fragment = surfaceModel.getLinearFragment( range );
fragment = matchItem.getExpandedFragment( fragment );
const id = matchItem.id;
if ( !fragmentCountsByItem.has( id ) ) {
fragmentCountsByItem.set( id, new Map() );
}
const fragRange = fragment.getSelection().getRange();
const key = `${ fragRange.start }-${ fragRange.end }`;
const fragMap = fragmentCountsByItem.get( id );
// The term is only relevant to the action if the matchItem has no expansion rules.
const entry = fragMap.get( key ) || { fragment, count: 0, term: matchItem.expand ? ' ' : term };
entry.count++;
fragMap.set( key, entry );
}
}
}
// Once we finish all the searches, we do another pass through the matched fragments
// so that we can handle matchItems with a min occurrences constraint.
for ( const [ id, fragMap ] of fragmentCountsByItem.entries() ) {
const matchItem = this.matchItemsById.get( id );
const min = matchItem.config.minOccurrences || 1;
for ( const { fragment, count, term } of fragMap.values() ) {
if ( count >= min ) {
const isValidMode = this.constructor.static.choices.some(
( choice ) => choice.modes.includes( matchItem.mode )
);
const mode = isValidMode ? matchItem.mode : '';
actions.push(
new mw.editcheck.TextMatchEditCheckAction( {
fragments: [ fragment ],
title: matchItem.title,
message: matchItem.message,
check: this,
mode,
matchItem,
term
} )
);
}
}
}
return actions;
};
mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( surfaceModel ) {
return this.handleListener( surfaceModel, 'onDocumentChange' );
};
/**
* Get a unique tag name for a given matchItem-term pair.
* Builds the tag name from:
* - the name of this editcheck
* - and the unique subtag of this matchitem-term pair
*
* @param {Object} matchItem
* @param {string} term
* @return {string} A tag name in the format 'textMatch-{subtag}'
*/
mw.editcheck.TextMatchEditCheck.prototype.getTagNameByMatchItem = function ( matchItem, term ) {
return this.constructor.static.name + matchItem.getSubTag( term );
};
// For now it doesn't make sense to run a TextMatchEditCheck in review mode
// as there isn't a way to edit the text.
mw.editcheck.TextMatchEditCheck.prototype.onBeforeSave = null;
mw.editcheck.TextMatchEditCheck.prototype.act = function ( choice, action /* , surface */ ) {
switch ( choice ) {
case 'dismiss':
this.dismiss( action );
break;
case 'delete':
action.fragments[ 0 ].removeContent();
break;
case 'accept': {
const fragment = action.fragments[ 0 ];
const oldWord = fragment.getText();
const matchItem = action.matchItem;
if ( !matchItem ) {
ve.log( `mw.editcheck.TextMatchEditCheck.prototype.act(): did not find matchItem for ${ oldWord }` );
return;
}
const newWord = matchItem.getReplacement( oldWord );
// TODO match case of old word
if ( !newWord ) {
ve.log( `mw.editcheck.TextMatchEditCheck.prototype.act(): did not find replacement for ${ oldWord }` );
return;
}
fragment.removeContent().insertContent( newWord );
}
}
return ve.createDeferred().resolve( {} );
};
mw.editcheck.editCheckFactory.register( mw.editcheck.TextMatchEditCheck );
/**
* TextMatchEditCheckAction
*
* Subclass of EditCheckAction to include information
* about the matchItem associated with this action
*
* @class
*
* @param {Object} config Configuration options
* @param {Object} config.matchItem the associated matchItem for this action
* @param {string} config.term term that prompted the action
*/
mw.editcheck.TextMatchEditCheckAction = function MWTextMatchEditCheckAction( config ) {
mw.editcheck.TextMatchEditCheckAction.super.call( this, config );
this.matchItem = config.matchItem;
this.term = config.term;
};
/* Inheritance */
OO.inheritClass( mw.editcheck.TextMatchEditCheckAction, mw.editcheck.EditCheckAction );
/* Events */
/**
* Fired when the user selects an action (e.g., clicks a suggestion button).
*
* @event mw.editcheck.EditCheckAction#act
* @param {jQuery.Promise} promise A promise that resolves when the action is complete
*/
/* Methods */
/**
* Compare to another action
*
* @param {mw.editcheck.EditCheckAction} other Other action
* @param {...any} args
* @return {boolean}
*/
mw.editcheck.TextMatchEditCheckAction.prototype.equals = function ( other, ...args ) {
if ( !this.constructor.super.prototype.equals.call( this, other, ...args ) ) {
return false;
}
return this.matchItem.id === other.matchItem.id;
};
/**
* Get unique tag name for this action
*
* @return {string} unique tag
*/
mw.editcheck.TextMatchEditCheckAction.prototype.getTagName = function () {
if ( !this.matchItem ) {
return this.check.getName();
}
return this.check.getTagNameByMatchItem( this.matchItem, this.term );
};
/**
* Get the name of the check type
*
* @return {string} Check type name
*/
mw.editcheck.TextMatchEditCheckAction.prototype.getName = function () {
return this.check.getName() + '-' + this.matchItem.id;
};
/**
* TextMatchItem
*
* Class to represent a single matchItem for TextMatchEditCheck
*
* @class
*
* @param {Object} item Match item
* @param {string} id ID of matchitem in config
* @param {Intl.Collator} collator Collator to use for comparisons
*/
mw.editcheck.TextMatchItem = function MWTextMatchItem( item, id, collator ) {
this.title = item.title;
this.mode = item.mode || '';
this.message = item.message;
this.config = ve.extendObject( {}, this.constructor.static.defaultConfig, item.config );
this.expand = item.expand;
this.listener = item.listener || null;
this.id = id;
this.collator = collator;
// Normalize queries to allow support for both objects and arrays
this.query = this.normalizeQuery( item.query );
};
/* Inheritance */
OO.initClass( mw.editcheck.TextMatchItem );
/* Static properties */
mw.editcheck.TextMatchItem.static.defaultConfig = {
enabled: true
};
/* Methods */
/**
* Transform any query type into a dictionary of terms and their replacements,
* with a null replacement if none exists
*
* @param {Object.<string,string>|string[]|string} query
* @return {Object.<string,string>} Dictionary of each term and its replacement
*/
mw.editcheck.TextMatchItem.prototype.normalizeQuery = function ( query ) {
if ( typeof query === 'string' ) {
query = [ query ];
}
if ( Array.isArray( query ) ) {
const normalized = Object.create( null );
for ( const word of query ) {
normalized[ word ] = null;
}
return normalized;
}
return query || Object.create( null );
};
/**
* @return {boolean} if this matchItem is configured to be case sensitive
*/
mw.editcheck.TextMatchItem.prototype.isCaseSensitive = function () {
return this.config && this.config.caseSensitive;
};
/**
* Return the corresponding replacement word,
* as defined for the given word in this matchItem's query
*
* @param {string} term to get replacement for
* @return {string} replacement term
*/
mw.editcheck.TextMatchItem.prototype.getReplacement = function ( term ) {
if ( this.isCaseSensitive() ) {
return this.query[ term ];
}
const key = Object.keys( this.query ).find(
( k ) => this.collator.compare( k, term ) === 0
);
return key ? this.query[ key ] : null;
};
/**
* Expand a fragment given the match item's config
*
* @param {ve.dm.SurfaceFragment} fragment
* @return {ve.dm.SurfaceFragment} Expanded fragment
*/
mw.editcheck.TextMatchItem.prototype.getExpandedFragment = function ( fragment ) {
switch ( this.expand ) {
case 'sentence':
// TODO: implement once unicodejs support is added
break;
case 'paragraph':
fragment = fragment.expandLinearSelection( 'closest', ve.dm.ContentBranchNode )
// …but that covered the entire CBN, we only want the contents
.adjustLinearSelection( 1, -1 );
break;
case 'word':
case 'siblings':
case 'parent':
fragment = fragment.expandLinearSelection( this.expand );
break;
}
return fragment;
};
/**
* Get a unique subtag for this matchitem-term pair.
* Builds the subtag from:
* - the index of the matchItem when created
* - and, optionally, the index of the term in the list of keys from the matchItem's query
*
* @param {string} term
* @return {string} A subtag in the format '-{matchIndex}-{termIndex}'
*/
mw.editcheck.TextMatchItem.prototype.getSubTag = function ( term ) {
const queries = Object.keys( this.query );
let termIndex;
if ( this.expand ) {
// This operates under the assumption that, if the expand property is set,
// there can only be one action from this matchitem for any given fragment.
return `-${ this.id }`;
}
if ( this.caseSensitive ) {
termIndex = queries.indexOf( term );
} else {
termIndex = queries.findIndex( ( q ) => this.collator.compare( q, term ) === 0 );
}
if ( !this.id || termIndex === -1 ) {
return '';
}
return `-${ this.id }-${ termIndex }`;
};