/**
* Edit check to detect generic text matches/replacements
*
* @class
* @extends mw.editcheck.BaseEditCheck
*
* @constructor
* @param {mw.editcheck.Controller} controller
* @param {Object} [config]
* @param {boolean} [includeSuggestions=false]
*/
mw.editcheck.TextMatchEditCheck = function MWTextMatchEditCheck() {
// Parent constructor
mw.editcheck.TextMatchEditCheck.super.apply( this, arguments );
let lang = mw.config.get( 'wgContentLanguage' );
if ( this.constructor.static.languageFallbacks[ lang ] ) {
lang = this.constructor.static.languageFallbacks[ lang ];
}
try {
lang = Intl.getCanonicalLocales( lang )[ 0 ];
} catch ( e ) {
mw.log.error( `TextMatchEditCheck falling back from invalid language '${ lang }'`, e );
lang = navigator.language;
}
this.lang = lang;
this.sensitivity = 'accent'; // TODO figure out how to determine this on an editcheck level
this.collator = new Intl.Collator( this.lang, { sensitivity: this.sensitivity } );
// Initialize lookup maps
this.matchRules = [];
};
/* Inheritance */
OO.inheritClass( mw.editcheck.TextMatchEditCheck, mw.editcheck.BaseEditCheck );
/* Static properties */
mw.editcheck.TextMatchEditCheck.static.name = 'textMatch';
// Only show replacement preview if found text and replacement
// are below a certain length, to avoid UI issues with long text.
const replaceTextLengthLimit = 100;
/**
* The configs of TextMatchEditCheck take priority over individual matchRule configs.
* So we make TextMatch’s defaults nonrestrictive,
* and let the finer limitations be handled by individual matchRules.
*/
mw.editcheck.TextMatchEditCheck.static.defaultConfig = ve.extendObject( {}, mw.editcheck.TextMatchEditCheck.super.static.defaultConfig, {
showAsCheck: true,
showAsSuggestion: true,
maximumEditCount: false,
minimumEditCount: false
} );
mw.editcheck.TextMatchEditCheck.static.choices = [
{
action: 'accept',
label: OO.ui.deferMsg( 'editcheck-dialog-action-replace' ),
modes: [ 'replace' ]
},
{
action: 'delete',
label: OO.ui.deferMsg( 'visualeditor-contextitemwidget-label-remove' ),
modes: [ 'delete' ]
},
{
action: 'dismiss',
label: OO.ui.deferMsg( 'editcheck-action-dismiss' ),
modes: [ '', 'info', 'replace', 'delete' ]
}
];
/**
* Map of known languages that might be in wgContentLanguage but aren't valid for Intl.Collator
*
* @type {Object}
*/
mw.editcheck.TextMatchEditCheck.static.languageFallbacks = {
'map-bms': 'jv-x-bms'
};
/**
* Object into which default matchRule configs can be placed
*
* This is largely a place for scripts to interact with the check
*
* @type {Object}
*/
mw.editcheck.TextMatchEditCheck.static.matchRules = {};
/**
* Promise which holds the loading and processing of matchRules
*
* @type {Promise<Object>}
*/
mw.editcheck.TextMatchEditCheck.static.matchRulesPromise = null;
/**
* Cache containing fully processed matchRules will all imports,
* as well as any TextFinders created for them
*
* @type {Object}
*/
mw.editcheck.TextMatchEditCheck.static.matchCache = {
rawMatchRules: null,
memoizedFinders: {}
};
/**
* Fetch corresponding MW file for any matchRules with the "import" property
* and leave all other matchRules unchanged
*
* @param {Object|Array} rawMatchRules Map of matchRule IDs to raw config objects, or array (deprecated)
* @return {Promise<Object>} Promise which resolves to map of processed matchRules
*/
mw.editcheck.TextMatchEditCheck.static.processMatchRules = function ( rawMatchRules ) {
const processed = {};
const pageMap = {};
const filenames = [];
Object.entries( rawMatchRules ).forEach( ( [ id, rule ] ) => {
if ( rule.import ) {
const importTitle = mw.Title.newFromText( rule.import );
if ( !importTitle || importTitle.getNamespaceId() !== mw.config.get( 'wgNamespaceIds' ).mediawiki ) {
mw.log.warn( `Skipped import for matchRule id:${ id } (${ rule.import } must be in mediawiki namespace.)` );
return;
}
if ( !importTitle.getMainText().match( /^Editcheck-config-.+\.json$/ ) ) {
mw.log.warn( `Skipped import for matchRule id:${ id } (${ rule.import } must be of the form Editcheck-config-<name>.json)` );
return;
}
filenames.push( rule.import );
pageMap[ id ] = rule.import;
} else {
processed[ id ] = rule;
}
} );
if ( filenames.length === 0 ) {
return Promise.resolve( processed );
}
return mw.editcheck.getMediaWikiJSON( filenames )
.then( ( imported ) => {
if ( imported ) {
Object.entries( pageMap ).forEach( ( [ id, filename ] ) => {
if ( imported.has( filename ) ) {
processed[ id ] = imported.get( filename );
}
} );
}
return processed;
} )
.catch( ( err ) => {
// If the api request fails entirely,
// we'll log it but continue with the non-imported configs
mw.log.error( ' Failed to import configs', err );
return processed;
} );
};
/**
* Ensure matchRules and any imported configs are loaded exactly once per edit session
*
* @return {Promise<Object>} Promise which resolves to processed matchRules
*/
mw.editcheck.TextMatchEditCheck.static.ensureMatchRulesLoaded = function () {
// If we've already started loading config, then every caller waits on same promise.
if ( this.matchRulesPromise ) {
return this.matchRulesPromise;
}
const rawMatchRules = Object.assign(
{},
mw.editcheck.TextMatchEditCheck.static.matchRules || {},
// In T424678 we renamed matchItems to matchRules, but allow 'matchItems' for backwards compatibility temporarily
ve.getProp( mw.editcheck.config, 'textMatch', 'matchRules' ) || ve.getProp( mw.editcheck.config, 'textMatch', 'matchItems' ) || {}
);
// Begin async processing and cache promise
this.matchRulesPromise = this.processMatchRules( rawMatchRules )
.then( ( processed ) => {
const cache = {
rawMatchRules: processed,
memoizedFinders: {}
};
// Reset the cache when we get new matchRules
this.matchCache = cache;
return cache;
} )
.catch( ( err ) => {
mw.log.error( 'Failed to process matchRules', err );
this.matchRulesPromise = null;
} );
return this.matchRulesPromise;
};
/**
* Create a prompt for a replacement dialog, if within length limits
*
* @param {string} foundText
* @param {string} replacement
* @return {string} prompt text
*/
mw.editcheck.TextMatchEditCheck.static.createReplacementPrompt = function ( foundText, replacement ) {
if (
replacement &&
foundText.length <= replaceTextLengthLimit &&
replacement.length <= replaceTextLengthLimit
) {
return ve.msg( 'editcheck-textmatch-replace', foundText, replacement );
}
return undefined;
};
/* Methods */
/**
* Create a matchRule instance for each matchRule
* NOTE: rawMatchRules should never be anything but this.constructor.static.matchCache.rawMatchRules
*
* @param {Object} rawMatchRules all matchRule objects from config
*/
mw.editcheck.TextMatchEditCheck.prototype.instantiateMatchRules = function ( rawMatchRules ) {
// Create matchRule instances
Object.entries( rawMatchRules ).forEach( ( [ id, rule ] ) => {
try {
const isValidMode = this.constructor.static.choices.some(
( choice ) => choice.modes.includes( rule.mode )
);
rule.mode = isValidMode ? rule.mode : '';
if ( !rule.expand ) {
if ( ve.getProp( rule, 'config', 'minOccurrences' ) || rule.minOccurrences ) {
mw.log.warn( 'MatchRule \'' + rule.title + '\' sets minOccurrences but is missing expand value.' );
}
}
const textMatchRule = new mw.editcheck.TextMatchRule( rule, id, this.collator );
this.matchRules.push( textMatchRule );
} catch ( e ) {
mw.log.error( `TextMatchEditCheck failed to instantiate rule '${ id }'`, e );
}
} );
};
/**
* @param {ve.dm.SurfaceModel} surfaceModel
* @param {string} listener
* @return {Promise<mw.editcheck.TextMatchEditCheckAction[]>}
*/
mw.editcheck.TextMatchEditCheck.prototype.handleListener = function ( surfaceModel, listener ) {
// wait here until matchRules are guaraunteed to exist!
return this.constructor.static.ensureMatchRulesLoaded()
.then( () => {
if ( !this.matchRules.length ) {
this.instantiateMatchRules( this.constructor.static.matchCache.rawMatchRules );
}
const finders = this.constructor.static.matchCache.memoizedFinders;
const actions = [];
const document = surfaceModel.getDocument();
const modified = this.getModifiedContentRanges( document );
for ( const matchRule of this.matchRules ) {
try {
const terms = Object.keys( matchRule.query );
// Check if action can be created for this range
const isUsableRange = ( range, tagName ) => {
if ( !modified.some( ( modRange ) => range.touchesRange( modRange ) ) ) {
return false;
}
if ( !this.isRangeValid( range, surfaceModel.documentModel ) ) {
return false;
}
if ( this.isDismissedRange( range, tagName ) ) {
return false;
}
if ( matchRule.listener && matchRule.listener !== listener ) {
return false;
}
if ( matchRule.inNode && !matchRule.isRangeInNode( range, surfaceModel ) ) {
return false;
}
// Above we checked for the overall textmatch config, but now
// we need to know if this rule is more-specific:
if ( !(
this.constructor.static.doesConfigMatch( matchRule.config, surfaceModel.documentModel, this.includeSuggestions ) &&
this.isRangeValid( range, surfaceModel.documentModel, matchRule.config )
) ) {
return false;
}
return true;
};
// Replacements with regular expressions work a bit differently,
// since we need access to the original pattern to perform the replacement.
// So we'll need to search by individual query, instead of grouping all the query terms into a set.
if ( matchRule.isRegExp && matchRule.mode === 'replace' ) {
Object.entries( matchRule.query ).forEach( ( [ pattern, replacer ] ) => {
const regex = new RegExp( pattern, 'g' + ( matchRule.isCaseSensitive() ? '' : 'i' ) );
const finder = new ve.dm.RegExpTextFinder( regex, { wholeWord: true } );
const regexRanges = document.findText( finder );
for ( const range of regexRanges ) {
const tagName = this.constructor.static.name + matchRule.getSubTag( pattern );
if ( !isUsableRange( range, tagName ) ) {
continue;
}
const term = surfaceModel.getLinearFragment( range ).getText();
// Regex replacements require special handling because the matched range may contain
// inline objects that getText() skips but that occupy positions in the linear data
// Note: the reason we call this here instead of act() is to display the replacement correctly in the action dialog
const replacementConfig = this.getRegexReplacementData( range, regex, replacer, matchRule.preserveCase, surfaceModel );
// if no valid replacement was generated, do nothing
if ( replacementConfig.replacement === undefined ) {
continue;
}
const fragment = matchRule.getExpandedFragment( surfaceModel.getLinearFragment( range ) );
actions.push( this.buildAction( matchRule, fragment, term, tagName, replacementConfig ) );
}
} );
continue;
}
// Create or retrieve the TextFinder for this match rule
if ( !finders[ matchRule.id ] ) {
let finder = null;
if ( matchRule.isRegExp ) {
const re = new RegExp( terms.join( '|' ), 'g' + ( matchRule.isCaseSensitive() ? '' : 'i' ) );
finder = new ve.dm.RegExpTextFinder( re,
{
wholeWord: true
}
);
} else {
finder = new ve.dm.SetTextFinder( new Set( terms ),
{
caseSensitiveString: matchRule.isCaseSensitive(),
wholeWord: true
} );
}
finders[ matchRule.id ] = new ve.dm.MemoizedTextFinder( finder );
}
// Find all ranges that match this rule's search terms
const ranges = document.findText( finders[ matchRule.id ] );
const fragMap = new Map();
for ( const range of ranges ) {
const term = surfaceModel.getLinearFragment( range ).getText();
const tagName = this.constructor.static.name + matchRule.getSubTag( term );
if ( !isUsableRange( range, tagName ) ) {
continue;
}
const fragment = matchRule.getExpandedFragment( surfaceModel.getLinearFragment( range ) );
const min = matchRule.minOccurrences;
// If this match rule requires a certain number of occurrences, start keeping track of those
if ( min ) {
const fragRange = fragment.getSelection().getRange();
const key = `${ fragRange.start }-${ fragRange.end }`;
const count = ( fragMap.get( key ) || 0 ) + 1;
fragMap.set( key, count );
// Use strict equality so that we can keep adding to the occurrences
// in this fragment while only creating an action once
if ( count !== min ) {
continue;
}
}
let replacement = matchRule.getReplacement( term );
if ( matchRule.mode === 'replace' ) {
if ( replacement !== null && matchRule.preserveCase ) {
replacement = mw.editcheck.applyCase( replacement, term, this.lang );
}
}
actions.push( this.buildAction( matchRule, fragment, term, tagName, { replacement } ) );
}
} catch ( e ) {
// Probably a regexp creation issue
mw.log.error( `TextMatchEditCheck failed to apply rule '${ matchRule.id }'`, e );
}
}
return actions;
} );
};
/**
* Build a TextMatchEditCheckAction
*
* @param {mw.editcheck.TextMatchEditCheck} matchRule
* @param {ve.dm.LinearFragment} fragment Fragment that the match covers, after optional expansion
* @param {string} term Individual term that triggered the match, before optional expansion
* @param {string} tagName Unique tag name for this matchRule+term pair
* @param {Object} [replacementConfig] Configuration for the replacement
* @param {string} [replacementConfig.replacement] Word or phrase to use as the replacement
* @param {ve.dm.LinearFragment|undefined} [replacementConfig.replacementFragment] Optional fragment that the narrowed replacement covers
* @param {function(): (string|undefined)|undefined} [replacementConfig.defaultPrompt] Optional special prompt to display
*
* @return {mw.editcheck.TextMatchEditCheckAction}
*/
mw.editcheck.TextMatchEditCheck.prototype.buildAction = function ( matchRule, fragment, term, tagName, replacementConfig ) {
const { replacement, replacementFragment, defaultPrompt } = replacementConfig || {};
let prompt = defaultPrompt;
if ( !prompt ) {
const foundText = fragment.getText();
if ( matchRule.mode === 'replace' ) {
prompt = this.constructor.static.createReplacementPrompt( foundText, replacement );
}
}
return new mw.editcheck.TextMatchEditCheckAction( {
fragments: [ fragment ],
prompt,
term,
replacement,
title: matchRule.title,
message: matchRule.message,
check: this,
ruleConfig: matchRule.config,
mode: matchRule.mode,
matchRuleId: matchRule.id,
tagName,
replacementFragment
} );
};
mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( surfaceModel ) {
return this.handleListener( surfaceModel, 'onDocumentChange' );
};
// 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 'delete':
action.fragments[ 0 ].removeContent();
action.select( surface, true );
return;
case 'accept': {
const replacementFragment = action.replacementFragment || action.fragments[ 0 ];
replacementFragment.insertContent( action.replacement, true );
action.select( surface, true );
return;
}
}
// Parent method
return mw.editcheck.TextMatchEditCheck.super.prototype.act.apply( this, arguments );
};
/**
* Compute the replacement string, target fragment, and prompt for a regex match that contains objects
*
* We need special handling when the matched range contains inline objects like templates that getText() omits
* but that occupy positions in the linear data. To handle this, we narrow the replacement to only the portion
* that actually changed by chopping the parts that the term and the replacement have in common.
*
* For ex, "foo bar baz" -> "foo qux baz" would become "bar" -> "qux" only.
*
* @param {ve.Range} range Range of the match
* @param {RegExp} regex Compiled regex used to find the match
* @param {string|Function} replacer Replacement string from the matchRule
* @param {boolean} preserveCase If the replacement should match the case of the found term
* @param {ve.dm.SurfaceModel} surfaceModel
* @return {{ replacement: string, replacementFragment: ve.dm.SurfaceFragment, defaultPrompt: function(): (string|undefined) }|{}}
* (the narrowed string to insert, the narrowed fragment to insert it into, and the string to display in the prompt)
*/
mw.editcheck.TextMatchEditCheck.prototype.getRegexReplacementData = function ( range, regex, replacer, preserveCase, surfaceModel ) {
const fragment = surfaceModel.getLinearFragment( range );
const termWithElements = fragment.getText( '\uFFFC' );
// Now we can apply the substitution to the placeholder-substituted string
const rawReplacement = termWithElements.replace( regex, replacer );
const fullReplacement = preserveCase ?
mw.editcheck.applyCase( rawReplacement, termWithElements, this.lang ) :
rawReplacement;
if ( fullReplacement === termWithElements ) {
// No real change can be suggested
return {};
}
// Find the range where the replacement differs from the term
let prefix = 0, suffix = 0;
while ( prefix < termWithElements.length && termWithElements[ prefix ] === fullReplacement[ prefix ] ) {
prefix++;
}
while (
suffix < termWithElements.length - prefix &&
suffix < fullReplacement.length - prefix &&
termWithElements[ termWithElements.length - suffix - 1 ] === fullReplacement[ fullReplacement.length - suffix - 1 ]
) {
suffix++;
}
const doc = surfaceModel.getDocument();
// Get human-readable labels for the prompt, lazily; actions are built a lot and then
// discarded, and the node-lookups here are the expensive part of this.
// Any elements in the prompt will be represented as their template name or node type
const defaultPrompt = () => {
// The fragment translates with the document, so the offsets must be read now.
// The content must not have changed, or the placeholder positions in
// termWithElements will no longer line up with it.
if ( fragment.getText( '\uFFFC' ) !== termWithElements ) {
return undefined;
}
const currentRange = fragment.getSelection().getRange();
const elementLabels = [];
for ( let i = 0; i < termWithElements.length; i++ ) {
if ( termWithElements[ i ] === '\uFFFC' ) {
const offset = currentRange.start + i;
let label = '';
// Discard the closing tag, as we only need the open tag for grabbing template name(s)
if ( doc.data.isOpenElementData( offset ) ) {
try {
const node = doc.getDocumentNode().getNodeFromOffset( offset + 1 );
label = node instanceof ve.dm.MWTransclusionNode ? node.getWikitext( true ) : `<${ node.getType() }>`;
} catch ( e ) {
mw.log.warn( 'TextMatch edit check: Failed to get label for inline element', e );
}
}
elementLabels.push( label );
}
}
let j = 0;
const termForPrompt = termWithElements.replace( /\uFFFC/g, () => elementLabels[ j++ ] || '' );
j = 0;
const replacementForPrompt = fullReplacement.replace( /\uFFFC/g, () => elementLabels[ j++ ] || '' );
return this.constructor.static.createReplacementPrompt( termForPrompt, replacementForPrompt );
};
const newRange = new ve.Range( range.start + prefix, range.end - suffix );
if (
!doc.data.isContentOffset( newRange.start ) ||
!doc.data.isContentOffset( newRange.end )
) {
// A boundary landed inside an inline node
return {};
}
const replacement = fullReplacement.slice( prefix, fullReplacement.length - suffix );
if ( replacement.includes( '\uFFFC' ) ) {
// The replacement still references objects we can't reproduce as text
return {};
}
return {
replacement,
replacementFragment: surfaceModel.getLinearFragment( newRange ),
defaultPrompt
};
};
/* Registration */
mw.editcheck.editCheckFactory.register( mw.editcheck.TextMatchEditCheck );
/**
* TextMatchEditCheckAction
*
* Subclass of EditCheckAction to include information about the matchRule associated with this action
*
* @class
* @extends mw.editcheck.EditCheckAction
*
* @constructor
* @param {Object} config Configuration options
* @param {string} config.matchRuleId ID of the matchRule that triggered the match
* @param {string} config.term Term that prompted the action
* @param {string} config.message Message for the action dialog
* @param {string} config.replacement Word or phrase to use as the replacement, if action allows
* @param {string} config.tagName Unique tag name for this matchRule+term pair
* @param {ve.dm.SurfaceFragment} [config.replacementFragment] Fragment to insert content into
*/
mw.editcheck.TextMatchEditCheckAction = function MWTextMatchEditCheckAction( config ) {
mw.editcheck.TextMatchEditCheckAction.super.call( this, config );
this.matchRuleId = config.matchRuleId;
this.term = config.term;
const msgkey = `editcheck-textmatch-${ config.matchRuleId }-description`;
ve.init.platform.addMessages( { [ msgkey ]: config.message } );
this.message = ve.deferJQueryMsg( msgkey );
this.replacement = config.replacement;
this.tagName = config.tagName;
this.replacementFragment = config.replacementFragment;
this.ruleConfig = config.ruleConfig;
};
/* 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.matchRuleId === other.matchRuleId;
};
/**
* Get unique tag name for this action
*
* @return {string} unique tag
*/
mw.editcheck.TextMatchEditCheckAction.prototype.getTagName = function () {
return this.tagName ? this.tagName : this.check.getName();
};
/**
* Get the name of the check type
*
* @return {string} Check type name
*/
mw.editcheck.TextMatchEditCheckAction.prototype.getName = function () {
return this.check.getName() + '-' + this.matchRuleId;
};
/**
* @inheritdoc
*/
mw.editcheck.TextMatchEditCheckAction.prototype.getRuleId = function () {
return this.matchRuleId;
};
mw.editcheck.TextMatchEditCheckAction.prototype.isExperimental = function () {
return this.constructor.super.prototype.isExperimental.apply( this, arguments ) ||
( !this.suggestion && !this.ruleConfig.showAsCheck ) ||
( this.suggestion && !this.ruleConfig.showAsSuggestion );
};
/**
* TextMatchRule
*
* Class to represent a single matchRule for TextMatchEditCheck
*
* @class
*
* @constructor
* @param {Object} rule Match rule
* @param {string} rule.title Title of the match rule, used in the action prompt
* @param {string} rule.message Message to show in the action description
* @param {Object.<string,string>|string[]|string} rule.query Terms to match, string, array or object mapping terms to their replacements.
* @param {string} [rule.mode] 'info', 'replace', or 'delete', to determine the type of action to show for this matchRule.
* @param {Object} [rule.config] Configuration options.
* @param {string} [rule.expand] Expansions mode 'sentence', 'paragraph', 'word', 'siblings', or 'parent'
* @param {string} [rule.inNode] Node type that a match must be inside of
* @param {string} [rule.listener] Listener that this matchRule applies to, if not all
* @param {boolean} [rule.preserveCase] If the replacement should match the case of the found term
* @param {boolean} [rule.isRegExp] If the query should be treated as a regular expression
* @param {boolean} [rule.caseSensitive] If search should be case sensitive
* @param {boolean} [rule.minOccurrences] Number of query instances that need to be found before it's declared a match
* @param {string} id ID of matchRule in config
* @param {Intl.Collator} collator Collator to use for comparisons
*/
mw.editcheck.TextMatchRule = function MWTextMatchRule( rule, id, collator ) {
this.title = rule.title;
this.mode = rule.mode || '';
this.message = rule.message;
this.config = ve.extendObject( {}, this.constructor.static.defaultConfig, rule.config );
this.expand = rule.expand;
this.inNode = rule.inNode || null;
this.listener = rule.listener || null;
// In T426004 we moved to defining caseSensitive and minOccurrences at the top-level of the matchItem config,
// but we'll support the previous way for backwards compatibility with any existing on-wiki configs
if ( rule.config ) {
if ( rule.config.caseSensitive ) {
rule.caseSensitive = rule.config.caseSensitive;
}
if ( rule.config.minOccurrences ) {
rule.minOccurrences = rule.config.minOccurrences;
}
}
this.caseSensitive = rule.caseSensitive;
this.minOccurrences = rule.minOccurrences;
// preserveCase defaults to true when caseSensitive is false or not set
this.preserveCase = ( rule.preserveCase !== null && rule.preserveCase !== undefined ) ? rule.preserveCase : !this.caseSensitive;
this.isRegExp = rule.isRegExp;
// If the selection is meant to be expanded, then only one action should be created per expanded fragment range
if ( this.expand && !this.minOccurrences ) {
this.minOccurrences = 1;
}
this.id = id;
this.collator = collator;
// Normalize queries to allow support for both objects and arrays
this.query = this.normalizeQuery( rule.query );
};
/* Inheritance */
OO.initClass( mw.editcheck.TextMatchRule );
/* Static properties */
mw.editcheck.TextMatchRule.static.defaultConfig = {
showAsCheck: false,
showAsSuggestion: 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.TextMatchRule.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 matchRule is configured to be case sensitive
*/
mw.editcheck.TextMatchRule.prototype.isCaseSensitive = function () {
return this.caseSensitive;
};
/**
* Check if a range is inside the required inNode type.
*
* @param {ve.Range} range
* @param {ve.dm.Surface} surfaceModel
* @return {boolean}
*/
mw.editcheck.TextMatchRule.prototype.isRangeInNode = function ( range, surfaceModel ) {
if ( !this.inNode ) {
return true;
}
const fragment = surfaceModel.getLinearFragment( range );
return fragment.hasMatchingAncestor( this.inNode );
};
/**
* Return the corresponding replacement word,
* as defined for the given word in this matchRule's query
*
* @param {string} term to get replacement for
* @return {string} replacement term
*/
mw.editcheck.TextMatchRule.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 rule's config
*
* @param {ve.dm.SurfaceFragment} fragment
* @return {ve.dm.SurfaceFragment} Expanded fragment
*/
mw.editcheck.TextMatchRule.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 matchRule-term pair.
* Builds the subtag from:
* - the index of the matchRule when created
* - and, optionally, the index of the term in the list of keys from the matchRule's query
*
* @param {string} term
* @return {string} A subtag in the format '-{matchIndex}-{termIndex}'
*/
mw.editcheck.TextMatchRule.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 matchRule for any given fragment.
return `-${ this.id }`;
}
if ( this.isCaseSensitive() ) {
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 }`;
};