2024-03-14 17:17:36 +00:00
|
|
|
mw.editcheck.BaseEditCheck = function MWBaseEditCheck( config ) {
|
2024-11-22 16:34:06 +00:00
|
|
|
this.config = ve.extendObject( {}, this.constructor.static.defaultConfig, config );
|
2024-03-14 17:17:36 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
OO.initClass( mw.editcheck.BaseEditCheck );
|
|
|
|
|
|
|
|
mw.editcheck.BaseEditCheck.static.onlyCoveredNodes = false;
|
|
|
|
|
|
|
|
mw.editcheck.BaseEditCheck.static.choices = [
|
|
|
|
{
|
|
|
|
action: 'accept',
|
|
|
|
label: ve.msg( 'editcheck-dialog-action-yes' ),
|
|
|
|
icon: 'check'
|
|
|
|
},
|
|
|
|
{
|
|
|
|
action: 'reject',
|
|
|
|
label: ve.msg( 'editcheck-dialog-action-no' ),
|
|
|
|
icon: 'close'
|
|
|
|
}
|
|
|
|
];
|
|
|
|
|
2024-11-22 16:34:06 +00:00
|
|
|
mw.editcheck.BaseEditCheck.static.defaultConfig = {
|
|
|
|
account: false, // 'loggedin', 'loggedout', anything non-truthy means allow either
|
|
|
|
maximumEditcount: 100,
|
|
|
|
ignoreSections: [],
|
|
|
|
ignoreLeadSection: false
|
|
|
|
};
|
|
|
|
|
2024-07-22 14:55:55 +00:00
|
|
|
mw.editcheck.BaseEditCheck.static.title = ve.msg( 'editcheck-review-title' );
|
|
|
|
|
2024-03-14 17:17:36 +00:00
|
|
|
mw.editcheck.BaseEditCheck.static.description = ve.msg( 'editcheck-dialog-addref-description' );
|
|
|
|
|
2024-07-22 14:55:55 +00:00
|
|
|
/**
|
|
|
|
* Get the name of the check type
|
|
|
|
*
|
|
|
|
* @return {string} Check type name
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getName = function () {
|
|
|
|
return this.constructor.static.name;
|
|
|
|
};
|
|
|
|
|
2024-03-14 17:17:36 +00:00
|
|
|
/**
|
2024-10-01 11:31:17 +00:00
|
|
|
* @param {ve.dm.Surface} surfaceModel
|
2024-03-14 17:17:36 +00:00
|
|
|
* @return {mw.editcheck.EditCheckAction[]}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.onBeforeSave = null;
|
|
|
|
|
|
|
|
/**
|
2024-10-01 11:31:17 +00:00
|
|
|
* @param {ve.dm.Surface} surfaceModel
|
2024-03-14 17:17:36 +00:00
|
|
|
* @return {mw.editcheck.EditCheckAction[]}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.onDocumentChange = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {string} choice `action` key from static.choices
|
|
|
|
* @param {mw.editcheck.EditCheckAction} action
|
2024-07-22 14:55:55 +00:00
|
|
|
* @param {ve.ui.Surface} surface
|
|
|
|
* @return {jQuery.Promise} Promise which resolves when action is complete
|
2024-03-14 17:17:36 +00:00
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.act = null;
|
|
|
|
|
2024-09-29 10:52:58 +00:00
|
|
|
/**
|
|
|
|
* @param {mw.editcheck.EditCheckAction} action
|
|
|
|
* @return {Object[]}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getChoices = function () {
|
2024-03-14 17:17:36 +00:00
|
|
|
return this.constructor.static.choices;
|
|
|
|
};
|
2024-09-29 10:52:58 +00:00
|
|
|
|
2024-07-22 14:55:55 +00:00
|
|
|
/**
|
|
|
|
* Get the title of the check
|
|
|
|
*
|
|
|
|
* @param {mw.editcheck.EditCheckAction} action
|
|
|
|
* @return {jQuery|string|Function|OO.ui.HtmlSnippet}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getTitle = function () {
|
|
|
|
return this.constructor.static.title;
|
|
|
|
};
|
|
|
|
|
2024-09-29 10:52:58 +00:00
|
|
|
/**
|
|
|
|
* @param {mw.editcheck.EditCheckAction} action
|
|
|
|
* @return {string}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getDescription = function () {
|
2024-03-14 17:17:36 +00:00
|
|
|
return this.constructor.static.description;
|
|
|
|
};
|
|
|
|
|
2024-09-04 15:23:43 +00:00
|
|
|
/**
|
|
|
|
* Find out whether the check should be applied
|
|
|
|
*
|
|
|
|
* This is a general check for its applicability to the viewer / page, rather
|
|
|
|
* than a specific check based on the current edit. It's used to filter out
|
|
|
|
* checks before any maybe-expensive content analysis happens.
|
|
|
|
*
|
|
|
|
* @return {boolean} Whether the check should be shown
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.canBeShown = function () {
|
|
|
|
// all checks are only in the main namespace for now
|
|
|
|
if ( mw.config.get( 'wgNamespaceNumber' ) !== mw.config.get( 'wgNamespaceIds' )[ '' ] ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
// some checks are configured to only be for logged in / out users
|
2024-09-09 15:50:11 +00:00
|
|
|
if ( mw.editcheck.ecenable ) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
// account status:
|
|
|
|
// loggedin, loggedout, or any-other-value meaning 'both'
|
|
|
|
// we'll count temporary users as "logged out" by using isNamed here
|
|
|
|
if ( this.config.account === 'loggedout' && mw.user.isNamed() ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if ( this.config.account === 'loggedin' && !mw.user.isNamed() ) {
|
2024-09-04 15:23:43 +00:00
|
|
|
return false;
|
2024-03-14 17:17:36 +00:00
|
|
|
}
|
2024-09-04 15:23:43 +00:00
|
|
|
// some checks are only shown for newer users
|
|
|
|
if ( this.config.maximumEditcount && mw.config.get( 'wgUserEditCount', 0 ) > this.config.maximumEditcount ) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
};
|
|
|
|
|
2024-09-29 10:52:58 +00:00
|
|
|
/**
|
|
|
|
* Get content ranges where at least the minimum about of text has been changed
|
|
|
|
*
|
2024-10-01 11:31:17 +00:00
|
|
|
* @param {ve.dm.Document} documentModel
|
2024-09-29 10:52:58 +00:00
|
|
|
* @return {ve.Range[]}
|
|
|
|
*/
|
2024-10-01 11:31:17 +00:00
|
|
|
mw.editcheck.BaseEditCheck.prototype.getModifiedContentRanges = function ( documentModel ) {
|
2024-11-25 16:46:02 +00:00
|
|
|
return this.getModifiedRanges( documentModel, this.constructor.static.onlyCoveredNodes, true );
|
|
|
|
};
|
|
|
|
|
2024-11-25 16:47:56 +00:00
|
|
|
/**
|
|
|
|
* Find nodes that were added during the edit session
|
|
|
|
*
|
|
|
|
* @param {ve.dm.Document} documentModel
|
|
|
|
* @param {string} [type] Type of nodes to find, or all nodes if false
|
|
|
|
* @return {ve.dm.Node[]}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getAddedNodes = function ( documentModel, type ) {
|
|
|
|
const matchedNodes = [];
|
|
|
|
this.getModifiedRanges( documentModel ).forEach( ( range ) => {
|
|
|
|
const nodes = documentModel.selectNodes( range, 'covered' );
|
|
|
|
nodes.forEach( ( node ) => {
|
|
|
|
if ( !type || node.node.getType() === type ) {
|
|
|
|
matchedNodes.push( node.node );
|
|
|
|
}
|
|
|
|
} );
|
|
|
|
} );
|
|
|
|
return matchedNodes;
|
|
|
|
};
|
|
|
|
|
2024-11-25 16:46:02 +00:00
|
|
|
/**
|
|
|
|
* Get content ranges which have been inserted
|
|
|
|
*
|
|
|
|
* @param {ve.dm.Document} documentModel
|
|
|
|
* @param {boolean} coveredNodesOnly Only include ranges which cover the whole of their node
|
|
|
|
* @param {boolean} onlyContentRanges Only return ranges which are content branch node interiors
|
|
|
|
* @return {ve.Range[]}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getModifiedRanges = function ( documentModel, coveredNodesOnly, onlyContentRanges ) {
|
|
|
|
if ( !documentModel.completeHistory.getLength() ) {
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
let operations;
|
|
|
|
try {
|
|
|
|
operations = documentModel.completeHistory.squash().transactions[ 0 ].operations;
|
|
|
|
} catch ( err ) {
|
|
|
|
// TransactionSquasher can sometimes throw errors; until T333710 is
|
|
|
|
// fixed just count this as not needing a reference.
|
|
|
|
mw.errorLogger.logError( err, 'error.visualeditor' );
|
|
|
|
return [];
|
|
|
|
}
|
|
|
|
|
|
|
|
const ranges = [];
|
|
|
|
let offset = 0;
|
|
|
|
const endOffset = documentModel.getDocumentRange().end;
|
|
|
|
operations.every( ( op ) => {
|
|
|
|
if ( op.type === 'retain' ) {
|
|
|
|
offset += op.length;
|
|
|
|
} else if ( op.type === 'replace' ) {
|
|
|
|
const insertedRange = new ve.Range( offset, offset + op.insert.length );
|
|
|
|
offset += op.insert.length;
|
|
|
|
// 1. Only trigger if the check is a pure insertion, with no adjacent content removed (T340088)
|
|
|
|
if ( op.remove.length === 0 ) {
|
|
|
|
if ( onlyContentRanges ) {
|
|
|
|
ve.batchPush(
|
|
|
|
ranges,
|
|
|
|
// 2. Only fully inserted paragraphs (ranges that cover the whole node) (T345121)
|
|
|
|
this.getContentRangesFromRange( documentModel, insertedRange, coveredNodesOnly )
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
ranges.push( insertedRange );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Reached the end of the doc / start of internal list, stop searching
|
|
|
|
return offset < endOffset;
|
|
|
|
} );
|
|
|
|
return ranges.filter( ( range ) => this.isRangeValid( range, documentModel ) );
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return the content ranges (content branch node interiors) contained within a range
|
|
|
|
*
|
|
|
|
* For a content branch node entirely contained within the range, its entire interior
|
|
|
|
* range will be included. For a content branch node overlapping with the range boundary,
|
|
|
|
* only the covered part of its interior range will be included.
|
|
|
|
*
|
|
|
|
* @param {ve.dm.Document} documentModel The documentModel to search
|
|
|
|
* @param {ve.Range} range The range to include
|
|
|
|
* @param {boolean} covers Only include ranges which cover the whole of their node
|
|
|
|
* @return {ve.Range[]} The contained content ranges (content branch node interiors)
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.getContentRangesFromRange = function ( documentModel, range, covers ) {
|
|
|
|
const ranges = [];
|
|
|
|
documentModel.selectNodes( range, 'branches' ).forEach( ( spec ) => {
|
|
|
|
if (
|
|
|
|
spec.node.canContainContent() && (
|
|
|
|
!covers || (
|
|
|
|
!spec.range || // an empty range means the node is covered
|
|
|
|
spec.range.equalsSelection( spec.nodeRange )
|
|
|
|
)
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
ranges.push( spec.range || spec.nodeRange );
|
|
|
|
}
|
|
|
|
} );
|
|
|
|
return ranges;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Test whether the range is valid for the check to apply
|
|
|
|
*
|
|
|
|
* @param {ve.Range} range
|
|
|
|
* @param {ve.dm.Document} documentModel
|
|
|
|
* @return {boolean}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.isRangeValid = function ( range, documentModel ) {
|
|
|
|
return this.isRangeInValidSection( range, documentModel );
|
2024-03-14 17:17:36 +00:00
|
|
|
};
|
|
|
|
|
2024-09-29 10:52:58 +00:00
|
|
|
/**
|
|
|
|
* Check if a modified range is a section we don't ignore (config.ignoreSections)
|
|
|
|
*
|
|
|
|
* @param {ve.Range} range
|
2024-09-29 11:06:26 +00:00
|
|
|
* @param {ve.dm.Document} documentModel
|
2024-09-29 10:52:58 +00:00
|
|
|
* @return {boolean}
|
|
|
|
*/
|
2024-09-29 11:06:26 +00:00
|
|
|
mw.editcheck.BaseEditCheck.prototype.isRangeInValidSection = function ( range, documentModel ) {
|
2024-03-14 17:17:36 +00:00
|
|
|
const ignoreSections = this.config.ignoreSections || [];
|
|
|
|
if ( ignoreSections.length === 0 && !this.config.ignoreLeadSection ) {
|
|
|
|
// Nothing is forbidden, so everything is permitted
|
|
|
|
return true;
|
|
|
|
}
|
2024-09-29 11:06:26 +00:00
|
|
|
const isHeading = ( nodeType ) => nodeType === 'mwHeading';
|
2024-03-14 17:17:36 +00:00
|
|
|
// Note: we set a limit of 1 here because otherwise this will turn around
|
|
|
|
// to keep looking when it hits the document boundary:
|
|
|
|
const heading = documentModel.getNearestNodeMatching( isHeading, range.start, -1, 1 );
|
|
|
|
if ( !heading ) {
|
|
|
|
// There's no preceding heading, so work out if we count as being in a
|
|
|
|
// lead section. It's only a lead section if there's more headings
|
|
|
|
// later in the document, otherwise it's just a stub article.
|
|
|
|
return !(
|
|
|
|
this.config.ignoreLeadSection &&
|
|
|
|
!!documentModel.getNearestNodeMatching( isHeading, range.start, 1 )
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if ( ignoreSections.length === 0 ) {
|
|
|
|
// There's nothing left to deny
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
const compare = new Intl.Collator( documentModel.getLang(), { sensitivity: 'accent' } ).compare;
|
|
|
|
const headingText = documentModel.data.getText( false, heading.getRange() );
|
2024-09-29 11:06:26 +00:00
|
|
|
// If the heading text matches any of ignoreSections, return false.
|
|
|
|
return !ignoreSections.some( ( section ) => compare( headingText, section ) === 0 );
|
2024-03-14 17:17:36 +00:00
|
|
|
};
|
2024-11-19 13:13:06 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Dismiss a check action
|
|
|
|
*
|
|
|
|
* @param {mw.editCheck.EditCheckAction} action
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.dismiss = function ( action ) {
|
|
|
|
const name = this.constructor.static.name;
|
|
|
|
if ( action.id ) {
|
|
|
|
const dismissedIds = mw.editcheck.dismissedIds;
|
|
|
|
dismissedIds[ name ] = dismissedIds[ name ] || [];
|
|
|
|
dismissedIds[ name ].push( action.id );
|
|
|
|
} else {
|
|
|
|
const dismissedFragments = mw.editcheck.dismissedFragments;
|
|
|
|
dismissedFragments[ name ] = dismissedFragments[ name ] || [];
|
|
|
|
dismissedFragments[ name ].push( ...action.fragments );
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if this type of check has been dismissed covering a specific range
|
|
|
|
*
|
|
|
|
* @param {ve.Range} range
|
|
|
|
* @return {boolean}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.isDismissedRange = function ( range ) {
|
|
|
|
const fragments = mw.editcheck.dismissedFragments[ this.constructor.static.name ];
|
|
|
|
return !!fragments && fragments.some(
|
|
|
|
( fragment ) => fragment.getSelection().getCoveringRange().containsRange( range )
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check if an action with a given ID has been dismissed
|
|
|
|
*
|
|
|
|
* @param {string} id
|
|
|
|
* @return {boolean}
|
|
|
|
*/
|
|
|
|
mw.editcheck.BaseEditCheck.prototype.isDismissedId = function ( id ) {
|
|
|
|
const ids = mw.editcheck.dismissedIds[ this.constructor.static.name ];
|
|
|
|
return ids && ids.indexOf( id ) !== -1;
|
|
|
|
};
|