/**
* Simple maintenance tools
*/
/* eslint-disable camelcase, no-var */
'use strict';
// IIFE used when including as a user script (to allow debug or config)
// Default gadget use will get an IIFE wrapper as well
( function ( $, mw, OO ) {
window.inductiveload = window.inductiveload || {}; // use window for ResourceLoader compatibility
if ( inductiveload.maintain ) {
return; // already initialised, don't overwrite
}
inductiveload.maintain = {
transforms: {}
};
/* -------------------------------------------------- */
/*
* Generic wrapper around MW.APi.get
*
* Returns a deferred. Results are resolve()'d after passing through the
* given filter functions.
*/
var mw_get_deferred = function ( params, result_filter, failure ) {
var deferred = $.Deferred();
new mw.Api().get( params )
.done( function ( data ) {
deferred.resolve( result_filter( data ) );
} )
.fail( function () {
deferred.resolve( failure() );
} );
return deferred;
};
var emptyArrayFunc = function () {
return [];
};
var emptyStringFunc = function () {
return '';
};
/*
* Get pages with prefix
*/
var get_page_suggestions = function ( input, namespaces ) {
var params = {
format: 'json',
formatversion: 2,
action: 'query',
list: 'prefixsearch',
pslimit: 15,
pssearch: input
};
if ( namespaces && namespaces.length ) {
params.apnamespace = namespaces.join( '|' );
}
return mw_get_deferred( params,
function ( data ) {
return data.query.prefixsearch;
},
emptyArrayFunc );
};
var get_last_edited = function ( user, namespaces ) {
var params = {
format: 'json',
formatversion: 2,
action: 'query',
list: 'usercontribs',
uclimit: 15,
ucuser: user
};
if ( namespaces && namespaces.length ) {
params.ucnamespace = namespaces.join( '|' );
}
return mw_get_deferred( params,
function ( data ) {
return data.query.usercontribs;
},
emptyArrayFunc );
};
var get_page_wikitext = function ( title ) {
var params = {
action: 'query',
format: 'json',
formatversion: 2,
prop: 'revisions',
rvslots: 'main',
rvprop: 'content',
titles: title
};
return mw_get_deferred( params,
function ( data ) {
return data.query.pages[ 0 ].revisions[ 0 ].slots.main.content;
},
emptyStringFunc
);
};
var get_last_contribs = function ( user, limit ) {
var params = {
action: 'query',
format: 'json',
formatversion: 2,
list: 'usercontribs',
ucuser: user,
limit: limit
};
return mw_get_deferred( params,
function ( data ) {
return data.query.usercontribs;
},
emptyArrayFunc
);
};
const getPageWikitext = function ( pageTitle ) {
const slot = 'main';
const params = {
action: 'query',
format: 'json',
formatversion: 2,
prop: 'revisions',
rvslots: slot,
rvprop: 'content',
titles: pageTitle,
rvlimit: 1
};
return mw_get_deferred( params,
function ( data ) {
return data.query.pages[ 0 ].revisions[ 0 ].slots[ slot ].content;
},
emptyArrayFunc
);
};
function unique_pages( a ) {
var seen = {};
return a.filter( function ( item ) {
return seen.hasOwnProperty( item.pageid ) ? false : ( seen[ item.pageid ] = true );
} );
}
/* -------------------------------------------------- */
/*
* A widget which looks up pages from a certain namespace by prefix
* If no input is given, it uses the previous edits of a given user
*/
var PageLookupTextInputWidget = function PageLookupTextInputWidget( config ) {
OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
OO.ui.mixin.LookupElement.call( this, $.extend( {
allowSuggestionsWhenEmpty: true
}, config ) );
this.namespaces = config.namespaces || [];
this.user = config.user;
};
OO.inheritClass( PageLookupTextInputWidget, OO.ui.TextInputWidget );
OO.mixinClass( PageLookupTextInputWidget, OO.ui.mixin.LookupElement );
PageLookupTextInputWidget.prototype.getLookupRequest = function () {
var value = this.getValue();
var deferred;
if ( !value && this.user ) {
deferred = get_last_edited( this.user, this.namespaces );
} else {
deferred = get_page_suggestions( value, this.namespaces );
}
return deferred.promise( {
abort: function () {}
} );
};
PageLookupTextInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
return response || [];
};
PageLookupTextInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
var mow_from_page = function ( page ) {
return new OO.ui.MenuOptionWidget( {
data: page.title,
label: page.title
} );
};
data = unique_pages( data );
return data.map( mow_from_page );
};
/* -------------------------------------------------- */
/*
* A widget which looks up contributions by a certain user
*/
var ContribLookupTextInputWidget = function ContribLookupTextInputWidget( config ) {
OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
OO.ui.mixin.LookupElement.call( this, $.extend( {
allowSuggestionsWhenEmpty: true
}, config ) );
this.namespace = config.namespace;
this.user = config.user;
};
OO.inheritClass( ContribLookupTextInputWidget, OO.ui.TextInputWidget );
OO.mixinClass( ContribLookupTextInputWidget, OO.ui.mixin.LookupElement );
ContribLookupTextInputWidget.prototype.getLookupRequest = function () {
var ns = this.namespace ? [ this.namespace ] : undefined;
var deferred = get_last_contribs( this.user, ns );
return deferred.promise( {
abort: function () {}
} );
};
ContribLookupTextInputWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
return response || [];
};
ContribLookupTextInputWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
var mow_from_contrib = function ( edit ) {
var time = edit.timestamp.replace( 'Z', '' ).replace( 'T', ' ' );
var size = ( ( edit.size >= 0 ) ? '+' : '' ) + String( edit.size );
var content = [
new OO.ui.HtmlSnippet( "<p class='userjs-maintain-extra'>" +
edit.revid + ' (' + size + ', ' + time + ')</p>' )
];
if ( edit.comment ) {
content.push(
new OO.ui.HtmlSnippet( "<p class='userjs-maintain-extra'>" + edit.comment + '</p>' )
);
}
return new OO.ui.MenuOptionWidget( {
data: edit.revid,
text: edit.title,
content: content
} );
};
return data.map( mow_from_contrib );
};
/* -------------------------------------------------- */
/*
* A widget which looks up wikitext lines from a page, optionally matching filters
*/
var WikitextLineLookup = function ( config ) {
OO.ui.TextInputWidget.call( this, $.extend( { /* validate: 'integer' */ }, config ) );
OO.ui.mixin.LookupElement.call( this, $.extend( {
allowSuggestionsWhenEmpty: true
}, config ) );
this.page = config.page;
const filters = config.filters;
this.wikitextPromise = getPageWikitext( this.page )
.then( ( wikitext ) => {
let lines = wikitext.split( '\n' );
if ( filters ) {
lines = lines.filter( ( line ) => {
for ( const filter of filters ) {
if ( filter.test( line ) ) {
return true;
}
}
return false;
} );
}
this.wikitextLines = lines;
} );
};
OO.inheritClass( WikitextLineLookup, OO.ui.TextInputWidget );
OO.mixinClass( WikitextLineLookup, OO.ui.mixin.LookupElement );
WikitextLineLookup.prototype.getLookupRequest = function () {
const deferred = $.Deferred();
const value = this.getValue().toLowerCase();
const matches = this.wikitextLines.filter( ( l ) => {
return l.toLowerCase().indexOf( value ) !== -1;
} );
// wait for the wikitext to load
this.wikitextPromise.then( () => {
deferred.resolve( matches );
} );
return deferred.promise( {
abort: function () {}
} );
};
WikitextLineLookup.prototype.getLookupCacheDataFromResponse = function ( response ) {
return response || [];
};
WikitextLineLookup.prototype.getLookupMenuOptionsFromData = function ( data ) {
var mowFromLine = function ( line ) {
return new OO.ui.MenuOptionWidget( {
data: line,
text: line
} );
};
return data.map( mowFromLine );
};
/* -------------------------------------------------- */
function PrimaryActionOnEnterMixin( /* config */ ) {
this.onDialogKeyDownHandler = this.onDialogKeyDown.bind( this );
}
OO.initClass( PrimaryActionOnEnterMixin );
PrimaryActionOnEnterMixin.prototype.onDialogKeyDown = function ( e ) {
var actions;
if ( e.which === OO.ui.Keys.ENTER ) {
actions = this.actions.get( { flags: 'primary', visible: true, disabled: false } );
if ( actions.length > 0 ) {
this.executeAction( actions[ 0 ].getAction() );
e.preventDefault();
e.stopPropagation();
}
} else if ( e.which === OO.ui.Keys.ESCAPE ) {
actions = this.actions.get( { flags: 'safe', visible: true, disabled: false } );
this.executeAction( actions[ 0 ].getAction() );
e.preventDefault();
e.stopPropagation();
}
};
/* -------------------------------------------------- */
var dialogs = {};
dialogs.ActionChooseDialog = function ( config ) {
dialogs.ActionChooseDialog.super.call( this, config );
// mixin constructors
PrimaryActionOnEnterMixin.call( this );
};
OO.inheritClass( dialogs.ActionChooseDialog, OO.ui.ProcessDialog );
OO.mixinClass( dialogs.ActionChooseDialog, PrimaryActionOnEnterMixin );
// Specify a name for .addWindows()
dialogs.ActionChooseDialog.static.name = 'ActionChooseDialog';
// Specify a title statically (or, alternatively, with data passed to the opening() method).
dialogs.ActionChooseDialog.static.title = 'Choose maintenance action';
dialogs.ActionChooseDialog.static.actions = [
{
action: 'save',
label: 'Done',
flags: [ 'primary' ],
modes: [ 'can_execute' ]
},
{
label: 'Cancel',
flags: [ 'safe' ],
modes: [ 'executing', 'can_execute' ]
}
];
dialogs.ActionChooseDialog.prototype.addFieldFromNeed = function ( need ) {
var input;
// most widgets can use this
var valuefunc = function ( i ) {
return i.getValue();
};
var eventName;
switch ( need.type ) {
case 'page':
input = new PageLookupTextInputWidget( {
namespaces: need.namespaces,
user: mw.config.get( 'wgUserName' )
// placeholder: "Index:Filename.djvu"
} );
break;
case 'text':
input = new OO.ui.TextInputWidget();
break;
case 'number':
input = new OO.ui.NumberInputWidget( {
label: need.label,
value: need.value,
min: need.min,
max: need.max
} );
break;
case 'bool':
input = new OO.ui.ToggleSwitchWidget( {
label: need.label,
value: need.value
} );
// normal valuefunc
break;
case 'choice':
input = new OO.ui.DropdownWidget( {
label: need.label,
menu: {
items: need.options.map( function ( c ) {
return new OO.ui.MenuOptionWidget( {
data: c.data,
label: c.label
} );
} )
}
} );
valuefunc = function ( i ) {
return i.getMenu().findSelectedItem().getData();
};
break;
case 'radio-button':
var items = need.options.map( function ( c ) {
return new OO.ui.ButtonOptionWidget( {
data: c.data,
label: c.label
} );
} );
input = new OO.ui.ButtonSelectWidget( {
items: items
} );
valuefunc = function ( i ) {
return i.findSelectedItem().getData();
};
eventName = 'choose';
break;
case 'contrib':
input = new ContribLookupTextInputWidget( {
user: need.user
} );
break;
case 'wikitext-line':
input = new WikitextLineLookup( {
filters: need.filters,
page: need.page || mw.config.get( 'wgPageName' )
} );
break;
default:
console.error( 'Unknown type ' + need.type );
}
if ( input ) {
this.inputs.push( {
widget: input,
valuefunc: valuefunc
} );
var fl = new OO.ui.FieldLayout( input, {
label: need.label,
help: need.help,
align: 'top'
} );
var dialog = this;
// if the need is submit=true
if ( need.submit && eventName ) {
input.on( eventName, function () {
dialog.executeAction( 'save' );
} );
}
// disable help tabbing, which gets in the way a LOT
fl.$element.find( '.oo-ui-fieldLayout-help a' )
.attr( 'tabindex', '-1' );
this.param_fieldset.addItems( [ fl ] );
}
};
// Customize the initialize() function: This is where to add content
// to the dialog body and set up event handlers.
dialogs.ActionChooseDialog.prototype.initialize = function () {
// Call the parent method.
dialogs.ActionChooseDialog.super.prototype.initialize.call( this );
// Create and append a layout and some content.
this.fieldset = new OO.ui.FieldsetLayout( {
label: 'Please choose an action',
classes: [ 'userjs-maintain_fs' ]
} );
this.$body.append( this.fieldset.$element );
var dialog = this;
this.buttonGroup = new OO.ui.ButtonSelectWidget( {
items: []
} );
this.fieldset.addItems( [ this.buttonGroup ] );
this.inputs = [];
this.param_fieldset = new OO.ui.FieldsetLayout( {
label: 'Action parameters',
classes: [ 'userjs-maintain_fs' ]
} );
this.$body.append( this.param_fieldset.$element );
this.param_fieldset.toggle( false );
this.buttonGroup.on( 'select', function ( e ) {
for ( var i = 0; i < dialog.inputs.length; ++i ) {
dialog.param_fieldset.clearItems();
}
dialog.inputs = [];
if ( !e ) {
return; // unselection
}
if ( e.data.needs ) {
for ( var n = 0; n < e.data.needs.length; ++n ) {
var need = e.data.needs[ n ];
dialog.addFieldFromNeed( need );
}
dialog.param_fieldset.toggle( dialog.inputs.length > 0 );
} else {
// immediate
dialog.executeAction( 'save' );
}
} );
};
dialogs.ActionChooseDialog.prototype.getActionProcess = function ( action ) {
var dialog = this;
if ( action === 'save' ) {
return new OO.ui.Process( function () {
var btn = dialog.buttonGroup.findSelectedItem();
var params = [];
for ( var i = 0; i < dialog.inputs.length; ++i ) {
params.push( dialog.inputs[ i ].valuefunc( dialog.inputs[ i ].widget ) );
}
if ( !btn ) {
OO.ui.alert( 'Please choose an action.' );
} else {
dialog.actions.setMode( 'executing' );
var accepted_promise = dialog.saveCallback( btn.data, params );
// close the dialog if the user accepted the edit
// and the edit succeeded
accepted_promise
.then( function () {
console.log( 'Accepted' );
dialog.close();
}, function () {
console.log( 'Not accepted/failed' );
dialog.buttonGroup.unselectItem();
dialog.actions.setMode( 'can_execute' );
} );
}
} );
} else {
return new OO.ui.Process( function () {
dialog.cancelCallback();
dialog.close();
} );
}
};
// Use getSetupProcess() to set up the window with data passed to it at the time
// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
dialogs.ActionChooseDialog.prototype.getSetupProcess = function ( data ) {
data = data || {};
return dialogs.ActionChooseDialog.super.prototype.getSetupProcess.call( this, data )
.next( function () {
var dialog = this;
var add_button = function ( opts ) {
var btn = new OO.ui.ButtonOptionWidget( {
label: opts.label,
title: opts.help,
data: opts
} );
dialog.buttonGroup.addItems( [ btn ] );
};
// Set up contents based on data
for ( var i = 0; i < data.tools.length; ++i ) {
var tool = data.tools[ i ];
add_button( tool );
}
this.saveCallback = data.saveCallback;
this.cancelCallback = data.cancelCallback;
this.actions.setMode( 'can_execute' );
}, this );
};
dialogs.ActionChooseDialog.prototype.getBodyHeight = function () {
// Note that "expanded: false" must be set in the panel's configuration for this to work.
// When working with a stack layout, you can use:
// return this.panels.getCurrentItem().$element.outerHeight( true );
return 300;
};
/* ---------------------------------------------------- */
function DiffConfirmDialog( config ) {
DiffConfirmDialog.super.call( this, config );
}
OO.inheritClass( DiffConfirmDialog, OO.ui.ProcessDialog );
// Specify a name for .addWindows()
DiffConfirmDialog.static.name = 'diffConfirmDialog';
// Specify a title statically (or, alternatively, with data passed to the opening() method).
DiffConfirmDialog.static.title = 'Confirm change';
DiffConfirmDialog.static.actions = [
{ action: 'save', label: 'Done', flags: 'primary' },
{ label: 'Cancel', flags: 'safe' }
];
// Customize the initialize() function: This is where to add content to
// the dialog body and set up event handlers.
DiffConfirmDialog.prototype.initialize = function () {
// Call the parent method.
DiffConfirmDialog.super.prototype.initialize.call( this );
// Create and append a layout and some content.
this.content = new OO.ui.PanelLayout( {
padded: true,
expanded: false
} );
this.$pageTitleElem = $( '<span>' );
$( '<p>' )
.append( 'Page: ', this.$pageTitleElem )
.appendTo( this.content.$element );
$( '<p>' )
.append( 'Please confirm the changes:' )
.appendTo( this.content.$element );
this.$body.append( this.content.$element );
this.summary = new OO.ui.TextInputWidget( {
placeholder: 'Summary'
} );
this.content.$element.append( this.summary.$element );
};
DiffConfirmDialog.prototype.getActionProcess = function ( action ) {
var dialog = this;
if ( !this.no_changes && action === 'save' ) {
return new OO.ui.Process( function () {
dialog.saveCallback( {
summary: dialog.summary.getValue()
} );
dialog.close();
} );
} else {
return new OO.ui.Process( function () {
dialog.cancelCallback();
dialog.close();
} );
}
};
// Use getSetupProcess() to set up the window with data passed to it at the time
// of opening (e.g., url: 'http://www.mediawiki.org', in this example).
DiffConfirmDialog.prototype.getSetupProcess = function ( data ) {
data = data || {};
var dialog = this;
return DiffConfirmDialog.super.prototype.getSetupProcess.call( this, data )
.next( function () {
// Set up contents based on data
dialog.saveCallback = data.saveCallback;
dialog.cancelCallback = data.cancelCallback;
dialog.pageTitle = data.pageTitle;
dialog.$pageTitleElem
.empty()
.append( $( '<a>' )
.attr( 'href', mw.config.get( 'wgArticlePath' ).replace( '$1', dialog.pageTitle ) )
.append( dialog.pageTitle )
);
var shortened = inductiveload.difference.shortenDiffString( data.diff, 100 ).join( '<hr />' );
if ( shortened && shortened.length ) {
this.content.$element.append( "<pre class='userjs-maintain-diff'>" + shortened + '</pre>' );
this.no_changes = false;
} else {
this.content.$element.append( '<br>', new OO.ui.MessageWidget( {
type: 'notice',
inline: true,
label: 'No changes made.'
} ).$element );
this.no_changes = true;
}
dialog.summary.setValue( data.summary );
}, this );
};
/* ---------------------------------------------------- */
function RegexTextInputWidget( config ) {
// Configuration initialization
config = $.extend( {
validate: this.validate,
getCaseSensitivity: function () { return true; }
}, config );
// Parent constructor
RegexTextInputWidget.super.call( this, config );
// Properties
this.text = null;
this.getCaseSensitivity = config.getCaseSensitivity;
this.getUseRegex = config.getUseRegex;
// Events
this.connect( this, {
change: 'onChange'
} );
// Initialization
this.setWorkingText( '' );
}
OO.inheritClass( RegexTextInputWidget, OO.ui.ComboBoxInputWidget );
RegexTextInputWidget.prototype.setWorkingText = function ( text ) {
this.text = text;
this.emit( 'change' );
};
RegexTextInputWidget.prototype.escapeRegex = function ( string ) {
return string.replace( /[-/\\^$*+?.()|[\]{}]/g, '\\$&' );
};
RegexTextInputWidget.prototype.makeRegexp = function ( value ) {
if ( !value ) {
value = this.getValue();
}
if ( !this.getUseRegex() ) {
value = this.escapeRegex( value );
}
var flags = 'g';
if ( !this.getCaseSensitivity() ) {
flags += 'i';
}
return new RegExp( value, flags );
};
RegexTextInputWidget.prototype.validate = function ( value ) {
if ( !this.getUseRegex() ) {
return true;
}
try {
this.makeRegexp( value );
} catch ( e ) {
return false;
}
return true;
};
RegexTextInputWidget.prototype.onChange = function ( value ) {
var label;
if ( !value ) {
value = this.getValue();
}
if ( !this.text ) {
label = 'loading';
} else if ( !value ) {
label = '';
} else {
try {
var regex = this.makeRegexp( value );
var matches = this.text.match( regex );
var count = matches ? matches.length : 0;
var suff = ( count === 1 ) ? 'match' : 'matches';
label = String( count ) + ' ' + suff;
} catch ( e ) {
label = 'bad regex';
}
}
this.setLabel( label );
};
/* ---------------------------------------------------- */
function AutocompleteController( entries ) {
this.maxEntries = 100;
this.entries = entries;
}
AutocompleteController.prototype.addEntry = function ( content ) {
// trim list and filter in-place
this.entries.splice( 0, this.maxEntries - 1, ...this.entries.filter( ( e ) => {
return e.content !== content;
} ) );
this.entries.push( {
content: content
} );
};
/* ---------------------------------------------------- */
function ReplaceDialog( config ) {
ReplaceDialog.super.call( this, config );
PrimaryActionOnEnterMixin.call( this );
this.storageId = 'gadget-maintain-replace-config';
this.config = mw.storage.getObject( this.storageId ) || {
patterns: [],
replacements: [],
isRegex: true,
caseSensitive: true,
version: 1
};
this.autocompletes = {
patterns: new AutocompleteController( this.config.patterns ),
replacements: new AutocompleteController( this.config.replacements )
};
}
OO.inheritClass( ReplaceDialog, OO.ui.ProcessDialog );
OO.mixinClass( ReplaceDialog, PrimaryActionOnEnterMixin );
// Specify a name for .addWindows()
ReplaceDialog.static.name = 'replaceDialog';
// Specify a title statically (or, alternatively, with data passed to the opening() method).
ReplaceDialog.static.title = 'Replace in page';
ReplaceDialog.static.actions = [
{ action: 'save', label: 'Done', flags: 'primary' },
{ label: 'Cancel', flags: 'safe' }
];
ReplaceDialog.prototype.storeConfig = function () {
mw.storage.setObject( this.storageId, this.config );
};
// Customize the initialize() function: This is where to add content to
// the dialog body and set up event handlers.
ReplaceDialog.prototype.initialize = function () {
// Call the parent method.
ReplaceDialog.super.prototype.initialize.call( this );
var dialog = this;
// Create and append a layout and some content.
this.fieldset = new OO.ui.FieldsetLayout( {
label: 'Replacement set-up',
classes: [ 'userjs-maintain_fs' ]
} );
// Add the FieldsetLayout to a FormLayout.
var form = new OO.ui.FormLayout( {
items: [ this.fieldset ]
} );
this.$body.append( form.$element );
this.inputs = {};
this.inputs.case_sensitive = new OO.ui.ToggleSwitchWidget( {
help: 'Case sensitive search',
value: true
} );
this.inputs.use_regex = new OO.ui.ToggleSwitchWidget( {
help: 'Regular expression search',
value: true
} );
this.inputs.search = new RegexTextInputWidget( {
placeholder: 'Search pattern',
name: 'search-pattern',
getCaseSensitivity: function () {
return dialog.inputs.case_sensitive.getValue();
},
getUseRegex: function () {
return dialog.inputs.use_regex.getValue();
},
options: this.config.patterns.map( ( r ) => {
return {
data: r.content,
label: r.content
};
} ),
menu: {
filterFromInput: true,
filterMode: 'substring'
}
} );
this.inputs.replace = new OO.ui.ComboBoxInputWidget( {
placeholder: 'Replacement pattern',
name: 'replace-pattern',
options: this.config.replacements.map( ( r ) => {
return {
data: r.content,
label: r.content
};
} ),
menu: {
filterFromInput: true,
filterMode: 'substring'
}
} );
this.inputs.case_sensitive.on( 'change', function () {
dialog.inputs.search.emit( 'change' );
} );
this.inputs.use_regex.on( 'change', function () {
dialog.inputs.search.emit( 'change' );
} );
this.fieldset.addItems( [
new OO.ui.FieldLayout( this.inputs.search, {
label: 'Search for',
align: 'right',
help: 'Enter the search pattern as a JS regex (without slashes). E.g. Chapter \\d+ to match chapter headings.'
} ),
new OO.ui.FieldLayout( this.inputs.replace, {
label: 'Replacement',
align: 'right',
help: 'Use $1, $2, etc. for captured groups.'
} ),
new OO.ui.FieldLayout( this.inputs.case_sensitive, {
label: 'Case sensitive',
align: 'right'
} ),
new OO.ui.FieldLayout( this.inputs.use_regex, {
label: 'Regular expression',
align: 'right',
help: 'Use a regular expression replacement pattern, rather than plain text.'
} )
] );
// disable help tabbing, which gets in the way a LOT
this.fieldset.$element.find( '.oo-ui-fieldLayout-help a' )
.attr( 'tabindex', '-1' );
};
ReplaceDialog.prototype.getSetupProcess = function ( data ) {
data = data || {};
var dialog = this;
return ReplaceDialog.super.prototype.getSetupProcess.call( this, data )
.next( function () {
// Set up contents based on data
this.saveCallback = data.saveCallback;
this.cancelCallback = data.cancelCallback;
this.pageTitle = data.pageTitle;
if ( data.selection ) {
this.inputs.search.setValue( data.selection );
this.inputs.replace.setValue( data.selection );
}
// get and cache the current wikitext
get_page_wikitext( this.pageTitle )
.done( function ( wikitext ) {
dialog.wikitext = wikitext;
dialog.inputs.search.setWorkingText( wikitext );
} );
}, this );
};
ReplaceDialog.prototype.getActionProcess = function ( action ) {
var unescapeBackslashes = function ( s ) {
return s.replace( /\\n/g, '\n' );
};
var dialog = this;
if ( !this.no_changes && action === 'save' ) {
const patternString = dialog.inputs.search.getValue();
const regex = dialog.inputs.search.makeRegexp( null );
const repl = dialog.inputs.replace.getValue();
// store autocompletion strings
this.autocompletes.patterns.addEntry( patternString );
this.autocompletes.replacements.addEntry( repl );
this.storeConfig();
return new OO.ui.Process( function () {
var summary = 'Replaced: ';
if ( dialog.inputs.use_regex.getValue() ) {
summary += regex + ' → ' + repl;
} else {
summary += patternString + ' → ' + repl;
}
var acceptedPromise = dialog.saveCallback( {
regex: regex,
replace: unescapeBackslashes( repl ),
summary: summary
} );
// close the dialog if the user accepted the edit
// and the edit succeeded
acceptedPromise
.then( function () {
dialog.close();
} );
} );
} else {
return new OO.ui.Process( function () {
dialog.close();
} );
}
};
/* ---------------------------------------------------- */
var Maintain = {
windowManager: undefined,
activated: false,
reloadOnChange: true,
defaultTags: [ 'maintain.js' ],
tools: [],
/* list of filters that match a tool and mean it doesn't need confirming */
noconfirm_tools: [],
signature: 'maintain_replace'
};
mw.messages.set( {
'maintainjs-name': 'maintain.js',
'maintainjs-docpage': 'User:Inductiveload/maintain'
} );
function getSummarySuffix() {
return '([[' + mw.msg( 'maintainjs-docpage' ) + '|' + mw.msg( 'maintainjs-name' ) + ']])';
}
function editPageApi( pageTitle, transformFunction, confirm, minor ) {
console.log( `Making API edit on ${pageTitle}` );
var api = new mw.Api();
var revid = mw.config.get( 'wgRevisionId' );
var title = pageTitle;
var promise;
if ( revid !== 0 ) {
// wrap the transform function to get the content out of a revision
const revEditFunction = function ( revision ) {
return transformFunction( revision.content, confirm )
.then( function ( transformed ) {
transformed.summary += ' ' + getSummarySuffix();
if ( minor !== undefined ) {
transformed.minor = minor;
}
return transformed;
} );
};
promise = api.edit( title, revEditFunction );
} else {
// do the transform ourselves on an empty string
var transform_promise = transformFunction( '', confirm );
promise = transform_promise
.then( function ( transformed ) {
api.create( title,
{
summary: transformed.summary + ' ' + getSummarySuffix()
},
transformed.text
);
} );
}
promise
.then( function () {
console.log( 'Page updated!' );
if ( Maintain.reloadOnChange ) {
location.reload();
}
}, function ( e, more ) {
// console.log("Edit/create rejected");
if ( e.name === 'EditCancelledException' ) {
// console.log(e.message);
return Promise.reject( 'EditCancelledException' );
} else {
OO.ui.alert(
more ? more.error.info : e, {
title: 'Edit failed'
} );
}
} )
.catch( function ( e ) {} );
return promise;
}
function editPageTextbox( transform_function, confirm ) {
console.log( 'Making textbox edit' );
// eslint-disable-next-line no-jquery/no-global-selector
var $input = $( '#wpTextbox1' );
// eslint-disable-next-line no-jquery/no-global-selector
var $summary = $( '#wpSummary' );
// do the transform ourselves on an empty string
var transform_promise = transform_function( $input.val(), confirm );
var promise = transform_promise
.then( function ( transformed ) {
$input.val( transformed.text );
var new_summary = $summary.val();
if ( new_summary ) {
new_summary += '; ';
}
new_summary += transformed.summary;
$summary.val( new_summary + ' ' + getSummarySuffix() );
},
function ( e ) {
// console.log("Edit/create rejected");
if ( e.name === 'EditCancelledException' ) {
// console.log(e.message);
return Promise.reject( 'EditCancelledException' );
}
} )
.catch( function ( e ) {
console.error( e );
} );
return promise;
}
/* ---------------------------------------------------- */
/*
* Edit the page with the given promise-returning function.
*
* Returns a promise that rejects if the promise does (for
* example, if the user rejects the change when prompted)
*/
function editPage( pageTitle, transformFunction, confirm, minor ) {
let retProm;
if ( [ 'edit', 'submit' ].indexOf( mw.config.get( 'wgAction' ) ) !== -1 ) {
// editing page - text in textbox
if ( pageTitle !== mw.config.get( 'wgPageName' ) ) {
alert( `Cannot edit page ${pageTitle} in this mode.` );
}
retProm = editPageTextbox( transformFunction, confirm, minor );
} else {
retProm = editPageApi( pageTitle, transformFunction, confirm, minor );
}
return retProm;
}
function confirmChange( title, old_text, new_text, summary ) {
var diff_html = inductiveload.difference.diffString( old_text, new_text, false );
// Make the window.
var dialog = new DiffConfirmDialog( {
size: 'medium'
} );
// eslint-disable-next-line compat/compat
var confirmPromise = new Promise( function ( resolve, reject ) {
// Create and append a window manager, which will open and close the window.
var windowManager = new OO.ui.WindowManager();
$( document.body ).append( windowManager.$element );
windowManager.addWindows( [ dialog ] );
windowManager.openWindow( dialog, {
diff: diff_html,
summary: summary,
pageTitle: title,
/* resolve the promise if we confirm */
saveCallback: function ( confirmed ) {
resolve( {
// user provided a better summary
summary: confirmed.summary
} );
},
cancelCallback: function () {
reject();
}
} );
} );
return confirmPromise;
}
function EditCancelledException() {
this.message = 'Edit cancelled';
this.name = 'EditCancelledException';
}
/* Make a transform to hand to the edit API
*
* Returns a functions that transforms text
* and returns promise that resolves if the change is accepted
* and is rejected on error or if the user rejects it.
*
* Resolution: {
* text: text,
* summary: summary
* }
*/
function getTransformAndConfirmFunction( title, selectionInfo, textTransform ) {
return function ( old_text, confirm ) {
old_text = old_text || '';
var transform_result = textTransform( old_text, selectionInfo );
// tranform failed! abort!
if ( transform_result === null ) {
return new Promise( function ( resolve, reject ) {
reject( 'Transform failed' );
} );
}
var ret = {
text: transform_result.text,
summary: transform_result.summary
};
// if (Maintain.defaultTags) {
// ret['tags'] = Maintain.defaultTags.join("|");
// }
// return a promise that resolves directly
if ( !confirm ) {
return new Promise( function ( resolve, reject ) {
resolve( ret );
} );
}
// return a promise that resolves with the transform
return confirmChange( title, old_text, transform_result.text, transform_result.summary )
.then( function ( confirmation ) {
// update in case user changed it
ret.summary = confirmation.summary;
return ret;
}, function () {
// rejection
console.log( 'User rejected' );
return Promise.reject( new EditCancelledException() );
// return null;
} );
};
}
function make_template( template, params, newlines ) {
var add = '{{' + template;
if ( params && params.length > 0 ) {
if ( newlines ) {
add += '\n | ';
} else {
add += '|';
}
add += params.join( newlines ? '|' : '\n | ' );
if ( newlines ) {
add += '\n';
}
}
add += '}}';
return add;
}
/*
* Wrap a template name in {{[[Template:%s|%s]]}} so it shows linked in edit
* summaries.
*/
function linkify_template( s ) {
s = s.replace( '{' + '{', '' ).replace( '}}', '' );
return '{' + '{' + '[' + '[Template:' + s + '|' + s + ']]}}';
}
/* Append text, skipping certain lines from the end */
function append_text( text, appended, skip_line_patts, add_line_after, add_line_before ) {
var lines = text.split( /\n/ );
var line_i = lines.length - 1;
var last_was_blank = false;
while ( line_i > 0 ) {
var line = lines[ line_i ];
var matched = false;
for ( var i = 0; i < skip_line_patts.length; ++i ) {
if ( skip_line_patts[ i ].test( line ) ) {
matched = true;
break;
}
}
if ( matched ) {
last_was_blank = /^\s*$/.test( line );
--line_i;
} else {
break;
}
}
if ( add_line_before ) {
lines[ line_i ] += '\n';
}
lines[ line_i ] += '\n' + appended;
if ( !last_was_blank && add_line_after ) {
lines[ line_i ] += '\n';
}
return lines.join( '\n' );
}
function prepend_xfrm( prefix, separation, summary ) {
return function ( old_text ) {
return {
text: prefix + ( old_text ? separation : '' ) + old_text,
summary: summary || ( "Prepended '" + prefix + "'" )
};
};
}
function append_xfrm( suffix, separation, summary ) {
return function ( old_text ) {
return {
text: old_text + ( old_text ? separation : '' ) + suffix,
summary: summary || ( 'Appended: ' + suffix )
};
};
}
function add_template_transform( append, template, params, config ) {
config = config || {};
var add = make_template( template, params, config.params_newlines );
if ( config.sign ) {
// eslint-disable-next-line no-useless-concat
add += ' ~' + '~' + '~' + '~'; // don't replace in JS source
}
var separation = config.separation || '\n';
var summary = config.summary ||
'Add ' + linkify_template( template );
if ( append ) {
return append_xfrm( add, separation, summary );
}
return prepend_xfrm( add, separation, summary );
}
function add_cat_xfrm( cat ) {
// stop MW categorising the JS page...
// eslint-disable-next-line no-useless-concat
var cat_str = '[[' + 'Category:' + cat + ']]';
return function ( old ) {
var lines = old.split( /\n/ );
var line_i = lines.length - 1;
while ( line_i > 0 && lines[ line_i ].trim().length === 0 ) {
line_i--;
}
var add_str = '\n' + cat_str;
if ( !lines[ line_i ].trim().startsWith( '[[Category' ) ) {
add_str = '\n' + add_str;
}
return {
text: old + add_str,
summary: 'Add category: ' + cat_str
};
};
}
/*
* Takes a list of [regex, repl] pairs and applies
* them in order.
*
* Returns the transform and the summary as an object
*/
function regexTransform( res, summary ) {
return function ( old ) {
for ( var i = 0; i < res.length; ++i ) {
old = old.replace( res[ i ][ 0 ], res[ i ][ 1 ] );
}
return {
text: old,
summary: summary
};
};
}
/*
* Takes a list of [needle, repl] pairs and applies
* them in order. The pattern is a plain string and will match exactly.
*
* Returns the transform and the summary as an object
*/
function replaceTransform( res, summary ) {
const regexExscapePatt = /[-[\]/{}()*+?.\\^$|]/g;
const escapeRegex = function ( needle ) {
return needle.replace( regexExscapePatt, '\\$&' );
};
const regexps = res.map( ( [ needle, repl ] ) => {
return [ new RegExp( escapeRegex( needle ) ), repl ];
} );
// defer to the generic regexp transform
return regexTransform( regexps, summary );
}
function delete_templates_transform( templates, summary ) {
return function ( old ) {
var removed = [];
for ( var i = 0; i < templates.length; ++i ) {
// TODO parse properly to matching braces
var re = new RegExp( '{{\\s*' + templates[ i ] + '(\\s*\\|.*?)?}}', 'i' );
var new_text = old.replace( re, '' );
if ( new_text !== old ) {
removed.push( templates[ i ] );
old = new_text;
}
}
if ( !summary ) {
removed = removed.map( linkify_template ).join( ', ' );
summary = 'Removed templates: ' + removed;
}
return {
text: old,
summary: summary
};
};
}
function chain_transform( transforms, summary ) {
return function ( old ) {
var summaries = [];
for ( var i = 0; i < transforms.length; ++i ) {
var res = transforms[ i ]( old );
if ( old !== res.text ) {
if ( !summary ) {
summaries.push( res.summary );
}
old = res.text;
}
}
return {
text: old,
summary: summary || summaries.join( '; ' )
};
};
}
/* export the transforms */
inductiveload.maintain.transforms = {
chain: chain_transform,
regex: regexTransform,
replace: replaceTransform,
add_category: add_cat_xfrm,
add_template: add_template_transform,
delete_templates: delete_templates_transform,
append: append_xfrm,
prepend: prepend_xfrm
};
inductiveload.maintain.utils = {
make_template_str: make_template,
linkify_template: linkify_template,
append_text: append_text
};
function generic_match( test, candidate ) {
if ( typeof test === 'string' ) {
if ( test === candidate ) {
return true;
}
} else if ( test instanceof RegExp ) {
if ( test.test( candidate ) ) {
return true;
}
} else if ( test instanceof Function ) {
if ( test( candidate ) ) {
return true;
}
}
return false;
}
function generic_match_any( test_list, candidate ) {
for ( var i = 0; i < test_list.length; ++i ) {
if ( generic_match( test_list[ i ], candidate ) ) {
return true;
}
}
return false;
}
function tool_needs_confirm( noconfirm_list, tool_id ) {
return !generic_match_any( noconfirm_list, tool_id );
}
function apply_config( cfg ) {
// add the config tools
[].push.apply( Maintain.tools, cfg.tools );
[].push.apply( Maintain.noconfirm_tools, cfg.noconfirm_tools );
}
function init() {
if ( !Maintain.activated ) {
Maintain.windowManager = new OO.ui.WindowManager();
// Create and append a window manager, which will open and close the window.
$( document.body ).append( Maintain.windowManager.$element );
var blank_cfg = {
tools: [],
noconfirm_tools: []
};
// user-provided configs
mw.hook( Maintain.signature + '.config' )
.fire( inductiveload.maintain, blank_cfg );
apply_config( blank_cfg );
Maintain.activated = true;
}
}
function getSelection() {
const selection = window.getSelection();
let pageName;
if ( !selection.isCollapsed && selection.rangeCount > 0 ) {
const $anchor = $( selection.anchorNode );
const $prpPageCont = $anchor.closest( '.prp-pages-output' );
if ( $prpPageCont.length > 0 ) {
// the selection is inside a PRP section
// find the page marker before the selection
const $pageMarkers = $prpPageCont.find( '.pagenum' );
const previous = [ ...$pageMarkers ].filter(
( elem ) => selection.anchorNode.compareDocumentPosition( elem ) ===
Node.DOCUMENT_POSITION_PRECEDING
).pop();
// pull the page name out of the data-page-name attribute
pageName = previous.getAttribute( 'data-page-name' );
}
}
if ( !pageName ) {
// use the current page
pageName = mw.config.get( 'wgPageName' );
}
return {
selection: selection.toString(),
pageTitle: pageName
};
}
function activate() {
init();
// Make the window.
var dialog = new dialogs.ActionChooseDialog( {
size: 'medium'
} );
Maintain.windowManager.addWindows( [ dialog ] );
const selectionInfo = getSelection();
Maintain.windowManager.openWindow( dialog, {
tools: Maintain.tools,
selection: selectionInfo.selection,
pageTitle: selectionInfo.pageTitle,
/* resolve the promise if we confirm */
saveCallback: function ( tool, params ) {
var transform = tool.transform( params );
if ( transform ) {
const title = mw.config.get( 'wgPageName' );
// convert to an API transform and attempt a page edit
var transformFn = getTransformAndConfirmFunction(
title, selectionInfo, transform );
var confirm = tool_needs_confirm( Maintain.noconfirm_tools, tool.id );
const minor = false;
var editPromise = editPage( title, transformFn, confirm, minor );
return editPromise;
} else {
return new Promise( function ( resolve, reject ) {
reject( 'Transform failed.' );
} );
}
},
cancelCallback: function () {}
} );
}
function activateReplace() {
init();
// Make the window.
var dialog = new ReplaceDialog( {
size: 'medium'
} );
$( document.body ).append( Maintain.windowManager.$element );
Maintain.windowManager.addWindows( [ dialog ] );
var selectionInfo = getSelection();
Maintain.windowManager.openWindow( dialog, {
selection: selectionInfo.selection,
pageTitle: selectionInfo.pageTitle,
saveCallback: function ( repl_data ) {
const regex = repl_data.regex;
const replc = repl_data.replace;
const summary = repl_data.summary;
const transform = regexTransform( [ [ regex, replc ] ],
summary );
const transform_fn = getTransformAndConfirmFunction(
selectionInfo.pageTitle, selectionInfo, transform );
const minor = true;
const edit_prom = editPage( selectionInfo.pageTitle, transform_fn, true, minor );
// return the edit promise - if this fails, we ask again
return edit_prom;
}
} );
}
function installPortlet() {
var portlet = mw.util.addPortletLink(
'p-tb',
'#',
'Maintenance',
't-maintenance',
'Maintenance tools'
);
$( portlet ).on( 'click', function ( e ) {
e.preventDefault();
activate();
} );
portlet = mw.util.addPortletLink(
'p-tb',
'#',
'Replace',
't-replace',
'Replace in page'
);
$( portlet ).on( 'click', function ( e ) {
e.preventDefault();
activateReplace();
} );
console.log( 'Portlet installed' );
}
function installCss( css ) {
// eslint-disable-next-line no-jquery/no-global-selector
$( 'head' ).append( '<style type="text/css">' + css + '</style>' );
}
$( function () {
installPortlet();
// Install CSS
installCss( `
.userjs-maintain_fs {
padding: 0 16px;
margin-top: 12px !important;
}
.userjs-maintain-extra {
font-size: 85%;
}
.userjs-maintain-diff ins {
color: seagreen;
}
.userjs-maintain-diff del {
color: tomato;
}
` );
} );
// eslint-disable-next-line no-undef
}( jQuery, mediaWiki, OO ) );