Edit check API

Change-Id: Ic5504eb2fe8d1d3f22e88abe1dd88790bdfd8b9c
This commit is contained in:
David Lynch 2024-03-14 10:17:36 -07:00 committed by Ed Sanders
parent d0a57f5197
commit d69d366469
11 changed files with 611 additions and 232 deletions

View file

@ -0,0 +1,98 @@
mw.editcheck.AddReferenceEditCheck = function MWAddReferenceEditCheck( config ) {
// Parent constructor
mw.editcheck.AddReferenceEditCheck.super.call( this, config );
};
OO.inheritClass( mw.editcheck.AddReferenceEditCheck, mw.editcheck.BaseEditCheck );
mw.editcheck.AddReferenceEditCheck.static.name = 'addReference';
mw.editcheck.AddReferenceEditCheck.static.description = ve.msg( 'editcheck-dialog-addref-description' );
mw.editcheck.AddReferenceEditCheck.prototype.onBeforeSave = function ( diff ) {
const documentModel = diff.documentModel;
const ranges = this.getModifiedRangesFromDiff( diff ).filter( ( range ) => {
// 4. Exclude any ranges that already contain references
for ( let i = range.start; i < range.end; i++ ) {
if ( documentModel.data.isElementData( i ) && documentModel.data.getType( i ) === 'mwReference' ) {
return false;
}
}
// 5. Exclude any ranges that aren't at the document root (i.e. image captions, table cells)
const branchNode = documentModel.getBranchNodeFromOffset( range.start );
if ( branchNode.getParent() !== documentModel.attachedRoot ) {
return false;
}
return true;
} );
return ranges.map( ( range ) => {
const fragment = diff.surface.getModel().getFragment( new ve.dm.LinearSelection( range ) );
return new mw.editcheck.EditCheckAction( {
highlight: fragment,
selection: this.adjustForPunctuation( fragment.collapseToEnd() ),
check: this
} );
} );
};
mw.editcheck.AddReferenceEditCheck.prototype.act = function ( choice, action, contextItem ) {
// The complex citoid workflow means that we can't just count on a single "windowAction" here...
const windowAction = ve.ui.actionFactory.create( 'window', contextItem.context.getSurface(), 'check' );
switch ( choice ) {
case 'accept':
ve.track( 'activity.editCheckReferences', { action: 'edit-check-confirm' } );
action.selection.select();
return windowAction.open( 'citoid' ).then( ( instance ) => instance.closing ).then( ( citoidData ) => {
const citoidOrCiteDataDeferred = ve.createDeferred();
if ( citoidData && citoidData.action === 'manual-choose' ) {
// The plain reference dialog has been launched. Wait for the data from
// the basic Cite closing promise instead.
contextItem.context.getSurface().getDialogs().once( 'closing', ( win, closed, citeData ) => {
citoidOrCiteDataDeferred.resolve( citeData );
} );
} else {
// "Auto"/"re-use"/"close" means Citoid is finished and we can
// use the data form the Citoid closing promise.
citoidOrCiteDataDeferred.resolve( citoidData );
}
citoidOrCiteDataDeferred.promise().then( ( data ) => {
if ( !data ) {
// Reference was not inserted - re-open this context
setTimeout( () => {
// Deactivate again for mobile after teardown has modified selections
contextItem.context.getSurface().getView().deactivate();
contextItem.context.afterContextChange();
}, 500 );
} else {
// Edit check inspector is already closed by this point, but
// we need to end the workflow.
contextItem.close( citoidData );
}
} );
} );
case 'reject':
ve.track( 'activity.editCheckReferences', { action: 'edit-check-reject' } );
return windowAction.open(
'editCheckReferencesInspector',
{
fragment: action.highlight,
callback: contextItem.data.callback,
saveProcessDeferred: contextItem.data.saveProcessDeferred
}
// eslint-disable-next-line arrow-body-style
).then( ( instance ) => {
// contextItem.openingCitoid = false;
return instance.closing;
} ).then( ( data ) => {
if ( !data ) {
// Form was closed, re-open this context
contextItem.context.afterContextChange();
} else {
contextItem.close( data );
}
} );
}
};
mw.editcheck.editCheckFactory.register( mw.editcheck.AddReferenceEditCheck );

View file

@ -0,0 +1,118 @@
mw.editcheck.BaseEditCheck = function MWBaseEditCheck( config ) {
this.config = config;
};
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'
}
];
mw.editcheck.BaseEditCheck.static.description = ve.msg( 'editcheck-dialog-addref-description' );
/**
* @param {mw.editcheck.Diff} diff
* @return {mw.editcheck.EditCheckAction[]}
*/
mw.editcheck.BaseEditCheck.prototype.onBeforeSave = null;
/**
* @param {mw.editcheck.Diff} diff
* @return {mw.editcheck.EditCheckAction[]}
*/
mw.editcheck.BaseEditCheck.prototype.onDocumentChange = null;
/**
* @param {string} choice `action` key from static.choices
* @param {mw.editcheck.EditCheckAction} action
* @param {ve.ui.EditCheckContextItem} contextItem
*/
mw.editcheck.BaseEditCheck.prototype.act = null;
mw.editcheck.BaseEditCheck.prototype.getChoices = function ( /* action */ ) {
return this.constructor.static.choices;
};
mw.editcheck.BaseEditCheck.prototype.getDescription = function ( /* action */ ) {
return this.constructor.static.description;
};
mw.editcheck.BaseEditCheck.prototype.getModifiedRangesFromDiff = function ( diff ) {
if ( !mw.editcheck.ecenable && this.config.maximumEditcount && mw.config.get( 'wgUserEditCount', 0 ) > this.config.maximumEditcount ) {
return [];
}
return diff.getModifiedRanges( this.constructor.static.onlyCoveredNodes )
.filter( ( range ) => this.shouldApplyToSection( diff, range ) && range.getLength() >= this.config.minimumCharacters );
};
mw.editcheck.BaseEditCheck.prototype.shouldApplyToSection = function ( diff, range ) {
const ignoreSections = this.config.ignoreSections || [];
if ( ignoreSections.length === 0 && !this.config.ignoreLeadSection ) {
// Nothing is forbidden, so everything is permitted
return true;
}
const documentModel = diff.documentModel;
const isHeading = function ( nodeType ) {
return nodeType === 'mwHeading';
};
// 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() );
for ( let i = ignoreSections.length - 1; i >= 0; i-- ) {
if ( compare( headingText, ignoreSections[ i ] ) === 0 ) {
return false;
}
}
return true;
};
mw.editcheck.BaseEditCheck.prototype.adjustForPunctuation = function ( insertionPointFragment ) {
if ( this.config.beforePunctuation ) {
// TODO: Use UnicodeJS properties directly once is https://gerrit.wikimedia.org/r/c/unicodejs/+/893832 merged
const sentenceProperties = {
ATerm: [ 0x002E, 0x2024, 0xFE52, 0xFF0E ],
STerm: [ 0x0021, 0x003F, 0x0589, 0x061E, 0x061F, 0x06D4, [ 0x0700, 0x0702 ], 0x07F9, 0x0837, 0x0839, 0x083D, 0x083E, 0x0964, 0x0965, 0x104A, 0x104B, 0x1362, 0x1367, 0x1368, 0x166E, 0x1735, 0x1736, 0x1803, 0x1809, 0x1944, 0x1945, [ 0x1AA8, 0x1AAB ], 0x1B5A, 0x1B5B, 0x1B5E, 0x1B5F, 0x1C3B, 0x1C3C, 0x1C7E, 0x1C7F, 0x203C, 0x203D, [ 0x2047, 0x2049 ], 0x2E2E, 0x2E3C, 0x3002, 0xA4FF, 0xA60E, 0xA60F, 0xA6F3, 0xA6F7, 0xA876, 0xA877, 0xA8CE, 0xA8CF, 0xA92F, 0xA9C8, 0xA9C9, [ 0xAA5D, 0xAA5F ], 0xAAF0, 0xAAF1, 0xABEB, 0xFE56, 0xFE57, 0xFF01, 0xFF1F, 0xFF61, 0x10A56, 0x10A57, [ 0x10F55, 0x10F59 ], 0x11047, 0x11048, [ 0x110BE, 0x110C1 ], [ 0x11141, 0x11143 ], 0x111C5, 0x111C6, 0x111CD, 0x111DE, 0x111DF, 0x11238, 0x11239, 0x1123B, 0x1123C, 0x112A9, 0x1144B, 0x1144C, 0x115C2, 0x115C3, [ 0x115C9, 0x115D7 ], 0x11641, 0x11642, [ 0x1173C, 0x1173E ], 0x11944, 0x11946, 0x11A42, 0x11A43, 0x11A9B, 0x11A9C, 0x11C41, 0x11C42, 0x11EF7, 0x11EF8, 0x16A6E, 0x16A6F, 0x16AF5, 0x16B37, 0x16B38, 0x16B44, 0x16E98, 0x1BC9F, 0x1DA88 ],
Close: [ 0x0022, [ 0x0027, 0x0029 ], 0x005B, 0x005D, 0x007B, 0x007D, 0x00AB, 0x00BB, [ 0x0F3A, 0x0F3D ], 0x169B, 0x169C, [ 0x2018, 0x201F ], 0x2039, 0x203A, 0x2045, 0x2046, 0x207D, 0x207E, 0x208D, 0x208E, [ 0x2308, 0x230B ], 0x2329, 0x232A, [ 0x275B, 0x2760 ], [ 0x2768, 0x2775 ], 0x27C5, 0x27C6, [ 0x27E6, 0x27EF ], [ 0x2983, 0x2998 ], [ 0x29D8, 0x29DB ], 0x29FC, 0x29FD, [ 0x2E00, 0x2E0D ], 0x2E1C, 0x2E1D, [ 0x2E20, 0x2E29 ], 0x2E42, [ 0x3008, 0x3011 ], [ 0x3014, 0x301B ], [ 0x301D, 0x301F ], 0xFD3E, 0xFD3F, 0xFE17, 0xFE18, [ 0xFE35, 0xFE44 ], 0xFE47, 0xFE48, [ 0xFE59, 0xFE5E ], 0xFF08, 0xFF09, 0xFF3B, 0xFF3D, 0xFF5B, 0xFF5D, 0xFF5F, 0xFF60, 0xFF62, 0xFF63, [ 0x1F676, 0x1F678 ] ],
SContinue: [ 0x002C, 0x002D, 0x003A, 0x055D, 0x060C, 0x060D, 0x07F8, 0x1802, 0x1808, 0x2013, 0x2014, 0x3001, 0xFE10, 0xFE11, 0xFE13, 0xFE31, 0xFE32, 0xFE50, 0xFE51, 0xFE55, 0xFE58, 0xFE63, 0xFF0C, 0xFF0D, 0xFF1A, 0xFF64 ]
};
const punctuationPattern = new RegExp(
unicodeJS.charRangeArrayRegexp( [].concat(
sentenceProperties.ATerm,
sentenceProperties.STerm,
sentenceProperties.Close,
sentenceProperties.SContinue
) )
);
let lastCharacter = insertionPointFragment.adjustLinearSelection( -1, 0 ).getText();
while ( punctuationPattern.test( lastCharacter ) ) {
insertionPointFragment = insertionPointFragment.adjustLinearSelection( -1, -1 );
lastCharacter = insertionPointFragment.adjustLinearSelection( -1, 0 ).getText();
}
}
return insertionPointFragment;
};

View file

@ -0,0 +1,35 @@
mw.editcheck.ConvertReferenceEditCheck = function MWConvertReferenceEditCheck( /* config */ ) {
// Parent constructor
mw.editcheck.ConvertReferenceEditCheck.super.apply( this, arguments );
};
OO.inheritClass( mw.editcheck.ConvertReferenceEditCheck, mw.editcheck.BaseEditCheck );
mw.editcheck.ConvertReferenceEditCheck.static.name = 'convertReference';
mw.editcheck.ConvertReferenceEditCheck.prototype.onDocumentChange = function ( diff ) {
const seenIndexes = {};
return diff.documentModel.getNodesByType( 'mwReference' ).map( ( node ) => {
const refModel = ve.dm.MWReferenceModel.static.newFromReferenceNode( node );
const index = refModel.getListIndex();
if ( seenIndexes[ index ] ) {
return null;
}
seenIndexes[ index ] = true;
const referenceNode = diff.documentModel.getInternalList().getItemNode( index );
const href = ve.ui.CitoidReferenceContextItem.static.getConvertibleHref( referenceNode );
if ( href ) {
const fragment = diff.surface.getModel().getFragment( new ve.dm.LinearSelection( node.getOuterRange() ) );
return new mw.editcheck.EditCheckAction( {
highlight: fragment,
selection: fragment,
message: ve.msg( 'citoid-referencecontextitem-convert-message' ),
check: this
} );
} else {
return null;
}
} ).filter( ( obj ) => obj );
};
mw.editcheck.editCheckFactory.register( mw.editcheck.ConvertReferenceEditCheck );

View file

@ -40,3 +40,17 @@
padding: 2px;
margin: -2px 0 0 -2px;
}
.ve-ce-surface-selections-editCheckWarning .ve-ce-surface-selection {
opacity: 0.2;
> div {
mix-blend-mode: darken;
// Adjust target colours to account for 50% opacity
background: ( #fef6e7 - 0.8 * ( #fff ) ) / 0.2;
border: 1px solid ( ( #a66200 - 0.8 * ( #fff ) ) / 0.2 );
border-radius: 2px;
padding: 2px;
margin: -2px 0 0 -2px;
}
}

View file

@ -0,0 +1,16 @@
mw.editcheck.EditCheckAction = function MWEditCheckAction( config ) {
this.check = config.check;
this.highlight = config.highlight;
this.selection = config.selection;
this.message = config.message;
};
OO.initClass( mw.editcheck.EditCheckAction );
mw.editcheck.EditCheckAction.prototype.getChoices = function () {
return this.check.getChoices( this );
};
mw.editcheck.EditCheckAction.prototype.getDescription = function () {
return this.check.getDescription( this );
};

View file

@ -41,18 +41,17 @@ ve.ui.EditCheckContextItem.static.label = OO.ui.deferMsg( 'editcheck-dialog-addr
* @inheritdoc
*/
ve.ui.EditCheckContextItem.prototype.renderBody = function () {
// Prompt panel
const acceptButton = new OO.ui.ButtonWidget( {
label: ve.msg( 'editcheck-dialog-action-yes' ),
icon: 'check'
} );
const rejectButton = new OO.ui.ButtonWidget( {
label: ve.msg( 'editcheck-dialog-action-no' ),
icon: 'close'
} );
const $actions = $( '<div>' ).addClass( 've-ui-editCheckContextItem-actions' );
acceptButton.connect( this, { click: 'onAcceptClick' } );
rejectButton.connect( this, { click: 'onRejectClick' } );
this.data.action.getChoices().forEach( ( choice ) => {
const button = new OO.ui.ButtonWidget( choice );
button.connect( this, {
click: () => {
this.onChoiceClick( choice.action );
}
} );
$actions.append( button.$element );
} );
// HACK: Suppress close button on mobile context
if ( this.context.isMobile() ) {
@ -60,10 +59,8 @@ ve.ui.EditCheckContextItem.prototype.renderBody = function () {
}
this.$body.append(
$( '<p>' ).text( ve.msg( 'editcheck-dialog-addref-description' ) ),
$( '<div>' ).addClass( 've-ui-editCheckContextItem-actions' ).append(
acceptButton.$element, rejectButton.$element
)
$( '<p>' ).text( this.data.action.getDescription() ),
$actions
);
};
@ -75,92 +72,8 @@ ve.ui.EditCheckContextItem.prototype.close = function ( data ) {
this.data.callback( data, this.data );
};
ve.ui.EditCheckContextItem.prototype.onAcceptClick = function () {
ve.track( 'activity.editCheckReferences', { action: 'edit-check-confirm' } );
const fragment = this.data.fragment;
const windowAction = ve.ui.actionFactory.create( 'window', this.context.getSurface(), 'check' );
let insertionPointFragment = fragment.collapseToEnd();
if ( mw.editcheck.config.addReference.beforePunctuation ) {
// TODO: Use UnicodeJS properties directly once is https://gerrit.wikimedia.org/r/c/unicodejs/+/893832 merged
const sentenceProperties = {
ATerm: [ 0x002E, 0x2024, 0xFE52, 0xFF0E ],
STerm: [ 0x0021, 0x003F, 0x0589, 0x061E, 0x061F, 0x06D4, [ 0x0700, 0x0702 ], 0x07F9, 0x0837, 0x0839, 0x083D, 0x083E, 0x0964, 0x0965, 0x104A, 0x104B, 0x1362, 0x1367, 0x1368, 0x166E, 0x1735, 0x1736, 0x1803, 0x1809, 0x1944, 0x1945, [ 0x1AA8, 0x1AAB ], 0x1B5A, 0x1B5B, 0x1B5E, 0x1B5F, 0x1C3B, 0x1C3C, 0x1C7E, 0x1C7F, 0x203C, 0x203D, [ 0x2047, 0x2049 ], 0x2E2E, 0x2E3C, 0x3002, 0xA4FF, 0xA60E, 0xA60F, 0xA6F3, 0xA6F7, 0xA876, 0xA877, 0xA8CE, 0xA8CF, 0xA92F, 0xA9C8, 0xA9C9, [ 0xAA5D, 0xAA5F ], 0xAAF0, 0xAAF1, 0xABEB, 0xFE56, 0xFE57, 0xFF01, 0xFF1F, 0xFF61, 0x10A56, 0x10A57, [ 0x10F55, 0x10F59 ], 0x11047, 0x11048, [ 0x110BE, 0x110C1 ], [ 0x11141, 0x11143 ], 0x111C5, 0x111C6, 0x111CD, 0x111DE, 0x111DF, 0x11238, 0x11239, 0x1123B, 0x1123C, 0x112A9, 0x1144B, 0x1144C, 0x115C2, 0x115C3, [ 0x115C9, 0x115D7 ], 0x11641, 0x11642, [ 0x1173C, 0x1173E ], 0x11944, 0x11946, 0x11A42, 0x11A43, 0x11A9B, 0x11A9C, 0x11C41, 0x11C42, 0x11EF7, 0x11EF8, 0x16A6E, 0x16A6F, 0x16AF5, 0x16B37, 0x16B38, 0x16B44, 0x16E98, 0x1BC9F, 0x1DA88 ],
Close: [ 0x0022, [ 0x0027, 0x0029 ], 0x005B, 0x005D, 0x007B, 0x007D, 0x00AB, 0x00BB, [ 0x0F3A, 0x0F3D ], 0x169B, 0x169C, [ 0x2018, 0x201F ], 0x2039, 0x203A, 0x2045, 0x2046, 0x207D, 0x207E, 0x208D, 0x208E, [ 0x2308, 0x230B ], 0x2329, 0x232A, [ 0x275B, 0x2760 ], [ 0x2768, 0x2775 ], 0x27C5, 0x27C6, [ 0x27E6, 0x27EF ], [ 0x2983, 0x2998 ], [ 0x29D8, 0x29DB ], 0x29FC, 0x29FD, [ 0x2E00, 0x2E0D ], 0x2E1C, 0x2E1D, [ 0x2E20, 0x2E29 ], 0x2E42, [ 0x3008, 0x3011 ], [ 0x3014, 0x301B ], [ 0x301D, 0x301F ], 0xFD3E, 0xFD3F, 0xFE17, 0xFE18, [ 0xFE35, 0xFE44 ], 0xFE47, 0xFE48, [ 0xFE59, 0xFE5E ], 0xFF08, 0xFF09, 0xFF3B, 0xFF3D, 0xFF5B, 0xFF5D, 0xFF5F, 0xFF60, 0xFF62, 0xFF63, [ 0x1F676, 0x1F678 ] ],
SContinue: [ 0x002C, 0x002D, 0x003A, 0x055D, 0x060C, 0x060D, 0x07F8, 0x1802, 0x1808, 0x2013, 0x2014, 0x3001, 0xFE10, 0xFE11, 0xFE13, 0xFE31, 0xFE32, 0xFE50, 0xFE51, 0xFE55, 0xFE58, 0xFE63, 0xFF0C, 0xFF0D, 0xFF1A, 0xFF64 ]
};
const punctuationPattern = new RegExp(
unicodeJS.charRangeArrayRegexp( [].concat(
sentenceProperties.ATerm,
sentenceProperties.STerm,
sentenceProperties.Close,
sentenceProperties.SContinue
) )
);
let lastCharacter = insertionPointFragment.adjustLinearSelection( -1, 0 ).getText();
while ( punctuationPattern.test( lastCharacter ) ) {
insertionPointFragment = insertionPointFragment.adjustLinearSelection( -1, -1 );
lastCharacter = insertionPointFragment.adjustLinearSelection( -1, 0 ).getText();
}
}
insertionPointFragment.select();
windowAction.open( 'citoid' ).then( ( instance ) => instance.closing ).then( ( citoidData ) => {
const citoidOrCiteDataDeferred = ve.createDeferred();
if ( citoidData && citoidData.action === 'manual-choose' ) {
// The plain reference dialog has been launched. Wait for the data from
// the basic Cite closing promise instead.
this.context.getSurface().getDialogs().once( 'closing', ( win, closed, citeData ) => {
citoidOrCiteDataDeferred.resolve( citeData );
} );
} else {
// "Auto"/"re-use"/"close" means Citoid is finished and we can
// use the data form the Citoid closing promise.
citoidOrCiteDataDeferred.resolve( citoidData );
}
citoidOrCiteDataDeferred.promise().then( ( data ) => {
if ( !data ) {
// Reference was not inserted - re-open this context
setTimeout( () => {
// Deactivate again for mobile after teardown has modified selections
this.context.getSurface().getView().deactivate();
this.context.afterContextChange();
}, 500 );
} else {
// Edit check inspector is already closed by this point, but
// we need to end the workflow.
this.close( citoidData );
}
} );
} );
};
ve.ui.EditCheckContextItem.prototype.onRejectClick = function () {
ve.track( 'activity.editCheckReferences', { action: 'edit-check-reject' } );
const windowAction = ve.ui.actionFactory.create( 'window', this.context.getSurface(), 'check' );
windowAction.open(
'editCheckReferencesInspector',
{
fragment: this.data.fragment,
callback: this.data.callback,
saveProcessDeferred: this.data.saveProcessDeferred
}
// eslint-disable-next-line arrow-body-style
).then( ( instance ) => {
// this.openingCitoid = false;
return instance.closing;
} ).then( ( data ) => {
if ( !data ) {
// Form was closed, re-open this context
this.context.afterContextChange();
} else {
this.close( data );
}
} );
ve.ui.EditCheckContextItem.prototype.onChoiceClick = function ( choice ) {
this.data.action.check.act( choice, this.data.action, this );
};
/* Registration */

View file

@ -0,0 +1,129 @@
/*!
* VisualEditor UserInterface EditCheckDialog class.
*
* @copyright See AUTHORS.txt
*/
/**
* Find and replace dialog.
*
* @class
* @extends ve.ui.ToolbarDialog
*
* @constructor
* @param {Object} [config] Configuration options
*/
ve.ui.EditCheckDialog = function VeUiEditCheckDialog( config ) {
// Parent constructor
ve.ui.EditCheckDialog.super.call( this, config );
// Pre-initialization
this.$element.addClass( 've-ui-editCheckDialog' );
};
/* Inheritance */
OO.inheritClass( ve.ui.EditCheckDialog, ve.ui.ToolbarDialog );
ve.ui.EditCheckDialog.static.name = 'editCheckDialog';
ve.ui.EditCheckDialog.static.position = 'side';
ve.ui.EditCheckDialog.static.size = 'medium';
ve.ui.EditCheckDialog.static.framed = false;
// // Invisible title for accessibility
// ve.ui.EditCheckDialog.static.title =
// OO.ui.deferMsg( 'visualeditor-find-and-replace-title' );
/* Methods */
/**
* @inheritdoc
*/
ve.ui.EditCheckDialog.prototype.initialize = function () {
// Parent method
ve.ui.EditCheckDialog.super.prototype.initialize.call( this );
this.updateDebounced = ve.debounce( this.update.bind( this ), 100 );
};
ve.ui.EditCheckDialog.prototype.update = function () {
const surfaceView = this.surface.getView();
const checks = mw.editcheck.editCheckFactory.createAllByListener( 'onDocumentChange', this.surface );
const $checks = $( '<div>' );
const selections = [];
checks.forEach( ( check ) => {
$checks.append( new OO.ui.MessageWidget( {
type: 'warning',
label: check.message,
framed: false
} ).$element );
selections.push( ve.ce.Selection.static.newFromModel( check.highlight.getSelection(), surfaceView ) );
} );
surfaceView.drawSelections( 'editCheckWarning', selections );
this.$body.empty().append( $checks );
};
/**
* @inheritdoc
*/
ve.ui.EditCheckDialog.prototype.getSetupProcess = function ( data ) {
return ve.ui.EditCheckDialog.super.prototype.getSetupProcess.call( this, data )
.first( () => {
this.surface = data.surface;
this.surface.getModel().on( 'undoStackChange', this.updateDebounced );
this.update();
}, this );
};
/**
* @inheritdoc
*/
ve.ui.EditCheckDialog.prototype.getReadyProcess = function ( data ) {
return ve.ui.EditCheckDialog.super.prototype.getReadyProcess.call( this, data )
.next( () => {
}, this );
};
/**
* @inheritdoc
*/
ve.ui.EditCheckDialog.prototype.getTeardownProcess = function ( data ) {
return ve.ui.EditCheckDialog.super.prototype.getTeardownProcess.call( this, data )
.next( () => {
this.surface.getModel().off( 'undoStackChange', this.updateDebounced );
}, this );
};
/* Registration */
ve.ui.windowFactory.register( ve.ui.EditCheckDialog );
ve.ui.commandRegistry.register(
new ve.ui.Command(
'editCheckDialog', 'window', 'toggle', { args: [ 'editCheckDialog' ] }
)
);
/**
* @class
* @extends ve.ui.ToolbarDialogTool
* @constructor
* @param {OO.ui.ToolGroup} toolGroup
* @param {Object} [config] Configuration options
*/
ve.ui.EditCheckDialogTool = function VeUiEditCheckDialogTool() {
ve.ui.EditCheckDialogTool.super.apply( this, arguments );
};
OO.inheritClass( ve.ui.EditCheckDialogTool, ve.ui.ToolbarDialogTool );
ve.ui.EditCheckDialogTool.static.name = 'editCheckDialog';
ve.ui.EditCheckDialogTool.static.group = 'notices';
ve.ui.EditCheckDialogTool.static.icon = 'robot';
ve.ui.EditCheckDialogTool.static.title = 'Edit check'; // OO.ui.deferMsg( 'visualeditor-dialog-command-help-title' );
ve.ui.EditCheckDialogTool.static.autoAddToCatchall = false;
ve.ui.EditCheckDialogTool.static.commandName = 'editCheckDialog';
// Demo button for opening edit check sidebar
// ve.ui.toolFactory.register( ve.ui.EditCheckDialogTool );

View file

@ -0,0 +1,72 @@
mw.editcheck.EditCheckFactory = function MWEditEditCheckFactory() {
// Parent constructor
mw.editcheck.EditCheckFactory.super.call( this, this.arguments );
this.checksByListener = {
onDocumentChange: [],
onBeforeSave: []
};
};
/* Inheritance */
OO.inheritClass( mw.editcheck.EditCheckFactory, OO.Factory );
/* Methods */
mw.editcheck.EditCheckFactory.prototype.register = function ( constructor, name ) {
name = name || ( constructor.static && constructor.static.name );
if ( typeof name !== 'string' || name === '' ) {
throw new Error( 'Check names must be strings and must not be empty' );
}
if ( !( constructor.prototype instanceof mw.editcheck.BaseEditCheck ) ) {
throw new Error( 'Checks must be subclasses of mw.editcheck.BaseEditCheck' );
}
if ( this.lookup( name ) === constructor ) {
// Don't allow double registration as it would create duplicate
// entries in various caches.
return;
}
// Parent method
mw.editcheck.EditCheckFactory.super.prototype.register.call( this, constructor, name );
if ( constructor.prototype.onDocumentChange ) {
this.checksByListener.onDocumentChange.push( name );
}
if ( constructor.prototype.onBeforeSave ) {
this.checksByListener.onBeforeSave.push( name );
}
};
/**
* Get a list of registered command names.
*
* @param {string} listener Listener name, 'onDocumentChange', 'onBeforeSave'
* @return {string[]}
*/
mw.editcheck.EditCheckFactory.prototype.getNamesByListener = function ( listener ) {
if ( !this.checksByListener[ listener ] ) {
throw new Error( `Unknown listener '${ listener }'` );
}
return this.checksByListener[ listener ];
};
mw.editcheck.EditCheckFactory.prototype.createAllByListener = function ( listener, surface ) {
const diff = new mw.editcheck.Diff( surface );
const newChecks = [];
this.getNamesByListener( listener ).forEach( ( checkName ) => {
const check = this.create( checkName, mw.editcheck.config[ checkName ] );
const actions = check[ listener ]( diff );
if ( actions.length > 0 ) {
ve.batchPush( newChecks, actions );
}
} );
newChecks.sort(
( a, b ) => a.highlight.getSelection().getCoveringRange().start - b.highlight.getSelection().getCoveringRange().start
);
return newChecks;
};
mw.editcheck.editCheckFactory = new mw.editcheck.EditCheckFactory();

View file

@ -0,0 +1,36 @@
mw.editcheck.TextMatchEditCheck = function MWTextMatchEditCheck( /* config */ ) {
// Parent constructor
mw.editcheck.TextMatchEditCheck.super.apply( this, arguments );
};
OO.inheritClass( mw.editcheck.TextMatchEditCheck, mw.editcheck.BaseEditCheck );
mw.editcheck.TextMatchEditCheck.static.name = 'textMatch';
mw.editcheck.TextMatchEditCheck.static.replacers = [
// TODO: Load text replacement rules from community config
{
query: 'unfortunately',
message: new OO.ui.HtmlSnippet( 'Use of adverbs such as "unfortunately" should usually be avoided so as to maintain an impartial tone. <a href="#">Read more</a>.' )
}
];
mw.editcheck.TextMatchEditCheck.prototype.onDocumentChange = function ( diff ) {
const actions = [];
this.constructor.static.replacers.forEach( ( replacer ) => {
diff.documentModel.findText( replacer.query ).forEach( ( range ) => {
const fragment = diff.surface.getModel().getFragment( new ve.dm.LinearSelection( range ) );
actions.push(
new mw.editcheck.EditCheckAction( {
highlight: fragment,
selection: fragment,
message: replacer.message,
check: this
} )
);
} );
} );
return actions;
};
mw.editcheck.editCheckFactory.register( mw.editcheck.TextMatchEditCheck );

View file

@ -1,9 +1,14 @@
mw.editcheck = {
config: require( './config.json' ),
ecenable: !!( new URL( location.href ).searchParams.get( 'ecenable' ) || window.MWVE_FORCE_EDIT_CHECK_ENABLED )
};
require( './EditCheckContextItem.js' );
require( './EditCheckInspector.js' );
mw.editcheck = {};
mw.editcheck.config = require( './config.json' );
require( './EditCheckDialog.js' );
require( './EditCheckFactory.js' );
require( './EditCheckAction.js' );
require( './BaseEditCheck.js' );
mw.editcheck.accountShouldSeeEditCheck = function ( config ) {
// account status:
@ -15,60 +20,52 @@ mw.editcheck.accountShouldSeeEditCheck = function ( config ) {
if ( config.account === 'loggedin' && !mw.user.isNamed() ) {
return false;
}
if ( config.maximumEditcount && mw.config.get( 'wgUserEditCount', 0 ) > config.maximumEditcount ) {
return false;
}
return true;
};
mw.editcheck.shouldApplyToSection = function ( documentModel, selection, config ) {
const ignoreSections = config.ignoreSections || [];
if ( ignoreSections.length === 0 && !config.ignoreLeadSection ) {
// Nothing is forbidden, so everything is permitted
return true;
}
const isHeading = function ( nodeType ) {
return nodeType === 'mwHeading';
};
// 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, selection.getRange().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 !(
config.ignoreLeadSection &&
!!documentModel.getNearestNodeMatching( isHeading, selection.getRange().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() );
for ( let i = ignoreSections.length - 1; i >= 0; i-- ) {
if ( compare( headingText, ignoreSections[ i ] ) === 0 ) {
return false;
}
}
return true;
};
// TODO: Load these checks behind feature flags
// require( './ConvertReferenceEditCheck.js' );
// require( './TextMatchEditCheck.js' );
if ( mw.editcheck.accountShouldSeeEditCheck( mw.editcheck.config.addReference ) || mw.editcheck.ecenable ) {
require( './AddReferenceEditCheck.js' );
}
/**
* Find added content in the document model that might need a reference
* Return the content ranges (content branch node interiors) contained within a range
*
* @param {ve.dm.DocumentModel} documentModel Document model
* @param {boolean} [includeReferencedContent] Include content ranges that already
* have a reference.
* @return {ve.dm.Selection[]} Content ranges that might need a reference
* 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.findAddedContentNeedingReference = function ( documentModel, includeReferencedContent ) {
if ( mw.config.get( 'wgNamespaceNumber' ) !== mw.config.get( 'wgNamespaceIds' )[ '' ] ) {
return [];
}
mw.editcheck.getContentRanges = 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;
};
mw.editcheck.Diff = function MWEditCheckDiff( surface ) {
this.surface = surface;
this.documentModel = surface.getModel().getDocument();
};
OO.initClass( mw.editcheck.Diff );
mw.editcheck.Diff.prototype.getModifiedRanges = function ( coveredNodesOnly ) {
const documentModel = this.documentModel;
if ( !documentModel.completeHistory.getLength() ) {
return [];
}
@ -96,66 +93,13 @@ mw.editcheck.findAddedContentNeedingReference = function ( documentModel, includ
ve.batchPush(
ranges,
// 2. Only fully inserted paragraphs (ranges that cover the whole node) (T345121)
mw.editcheck.getContentRanges( documentModel, insertedRange, true )
mw.editcheck.getContentRanges( documentModel, insertedRange, coveredNodesOnly )
);
}
}
// Reached the end of the doc / start of internal list, stop searching
return offset < endOffset;
} );
const addedTextRanges = ranges.filter( ( range ) => {
const minimumCharacters = mw.editcheck.config.addReference.minimumCharacters;
// 3. Check that at least minimumCharacters characters have been inserted sequentially
if ( range.getLength() >= minimumCharacters ) {
// 4. Exclude any ranges that already contain references
if ( !includeReferencedContent ) {
for ( let i = range.start; i < range.end; i++ ) {
if ( documentModel.data.isElementData( i ) && documentModel.data.getType( i ) === 'mwReference' ) {
return false;
}
}
}
// 5. Exclude any ranges that aren't at the document root (i.e. image captions, table cells)
const branchNode = documentModel.getBranchNodeFromOffset( range.start );
if ( branchNode.getParent() !== documentModel.attachedRoot ) {
return false;
}
return true;
}
return false;
} );
return addedTextRanges
.map( ( range ) => new ve.dm.LinearSelection( range ) )
.filter( ( selection ) => mw.editcheck.shouldApplyToSection( documentModel, selection, mw.editcheck.config.addReference ) );
};
/**
* 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.getContentRanges = 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;
};
@ -208,13 +152,9 @@ if ( mw.config.get( 'wgVisualEditorConfig' ).editCheckTagging ) {
} );
}
if (
( mw.config.get( 'wgVisualEditorConfig' ).editCheck && mw.editcheck.accountShouldSeeEditCheck( mw.editcheck.config.addReference ) ) ||
// ecenable will bypass normal account-status checks as well:
new URL( location.href ).searchParams.get( 'ecenable' ) ||
!!window.MWVE_FORCE_EDIT_CHECK_ENABLED
) {
if ( mw.config.get( 'wgVisualEditorConfig' ).editCheck || mw.editcheck.ecenable ) {
let saveProcessDeferred;
mw.hook( 've.preSaveProcess' ).add( ( saveProcess, target ) => {
const surface = target.getSurface();
@ -229,9 +169,8 @@ if (
// clear rejection-reasons between runs of the save process, so only the last one counts
mw.editcheck.rejections.length = 0;
let selections = mw.editcheck.findAddedContentNeedingReference( surface.getModel().getDocument() );
if ( selections.length ) {
let checks = mw.editcheck.editCheckFactory.createAllByListener( 'onBeforeSave', surface );
if ( checks.length ) {
mw.editcheck.refCheckShown = true;
const surfaceView = surface.getView();
@ -272,19 +211,22 @@ if (
saveProcessDeferred = ve.createDeferred();
const context = surface.getContext();
// TODO: Allow multiple selections to be shown when multicheck is enabled
selections = selections.slice( 0, 1 );
// TODO: Allow multiple checks to be shown when multicheck is enabled
checks = checks.slice( 0, 1 );
// eslint-disable-next-line no-shadow
const drawSelections = ( selections ) => {
const drawSelections = ( checks ) => {
const highlightNodes = [];
selections.forEach( ( selection ) => {
highlightNodes.push.apply( highlightNodes, surfaceView.getDocument().selectNodes( selection.getCoveringRange(), 'branches' ).map( ( spec ) => spec.node ) );
const selections = [];
checks.forEach( ( check ) => {
highlightNodes.push.apply( highlightNodes, surfaceView.getDocument().selectNodes( check.highlight.getSelection().getCoveringRange(), 'branches' ).map( ( spec ) => spec.node ) );
const selection = ve.ce.Selection.static.newFromModel( check.highlight.getSelection(), surfaceView );
selections.push( selection );
} );
// TODO: Make selections clickable when multicheck is enabled
surfaceView.drawSelections(
'editCheck',
selections.map( ( selection ) => ve.ce.Selection.static.newFromModel( selection, surfaceView ) )
checks.map( ( check ) => ve.ce.Selection.static.newFromModel( check.highlight.getSelection(), surfaceView ) )
);
surfaceView.setReviewMode( true, highlightNodes );
};
@ -294,7 +236,7 @@ if (
// this is the back button
return saveProcessDeferred.resolve();
}
const selectionIndex = selections.indexOf( contextData.selection );
const selectionIndex = checks.indexOf( contextData.action );
if ( responseData.action !== 'reject' ) {
mw.notify( ve.msg( 'editcheck-dialog-addref-success-notify' ), { type: 'success' } );
@ -302,18 +244,18 @@ if (
mw.editcheck.rejections.push( responseData.reason );
}
// TODO: Move on to the next issue, when multicheck is enabled
// selections = mw.editcheck.findAddedContentNeedingReference( surface.getModel().getDocument() );
selections = [];
// checks = mw.editcheck.editCheckFactory.createAllByListener( 'onBeforeSave', surface );
checks = [];
if ( selections.length ) {
if ( checks.length ) {
context.removePersistentSource( 'editCheckReferences' );
setTimeout( () => {
// timeout needed to wait out the newly added content being focused
surface.getModel().setNullSelection();
drawSelections( selections );
drawSelections( checks );
setTimeout( () => {
// timeout needed to allow the context to reposition
showCheckContext( selections[ Math.min( selectionIndex, selections.length - 1 ) ] );
showCheckContext( checks[ Math.min( selectionIndex, checks.length - 1 ) ] );
} );
}, 500 );
} else {
@ -322,8 +264,8 @@ if (
};
// eslint-disable-next-line no-inner-declarations
function showCheckContext( selection ) {
const fragment = surface.getModel().getFragment( selection, true );
function showCheckContext( check ) {
const fragment = check.highlight;
// Select the found content to correctly position the context on desktop
fragment.select();
@ -331,8 +273,8 @@ if (
context.addPersistentSource( {
embeddable: false,
data: {
action: check,
fragment: fragment,
selection: selection,
callback: contextDone,
saveProcessDeferred: saveProcessDeferred
},
@ -348,13 +290,12 @@ if (
} );
}
drawSelections( selections );
drawSelections( checks );
toolbar.toggle( false );
target.onContainerScroll();
saveProcess.next( () => {
showCheckContext( selections[ 0 ] );
showCheckContext( checks[ 0 ] );
return saveProcessDeferred.promise().then( ( data ) => {
context.removePersistentSource( 'editCheckReferences' );

View file

@ -632,6 +632,13 @@
"editcheck/modules/init.js",
"editcheck/modules/EditCheckContextItem.js",
"editcheck/modules/EditCheckInspector.js",
"editcheck/modules/EditCheckDialog.js",
"editcheck/modules/EditCheckFactory.js",
"editcheck/modules/EditCheckAction.js",
"editcheck/modules/BaseEditCheck.js",
"editcheck/modules/AddReferenceEditCheck.js",
"editcheck/modules/ConvertReferenceEditCheck.js",
"editcheck/modules/TextMatchEditCheck.js",
{
"name": "editcheck/modules/config.json",
"callback": "\\MediaWiki\\Extension\\VisualEditor\\EditCheck\\ResourceLoaderData::getConfig"