User:Evad37/Xunlink.js

/*************************************************************************************************** Xunlink --- by Evad37 > The power of XFDcloser's 'unlink backlinks' function, for any page. /* jshint esversion: 6, laxbreak: true, undef: true, maxerr:999 */ /* globals console, window, $, mw, OO, extraJs */ // $( function($) { /* ========== Configuration ===================================================================== */ var config = {	// Script info	script: {		// Advert to append to edit summaries		advert: ' (Xunlink)',		version: '2.0.1'	},	// MediaWiki configuration values	mw: mw.config.get( [ 'wgArticleId', 'wgPageName', 'wgUserGroups', 'wgUserName', 'wgFormattedNamespaces', 'wgMonthNames', 'wgNamespaceNumber' ] ),	allowedNamespaces: [0, 6, 100] // article, File, Portal }; // xfd props, for compatbility with code from XFDcloser config.xfd = {	// Namespaces to unlink from: main, Template, Portal, Draft	ns_unlink: ['0', '10', '100', '118'],	// Type (files get treated differently)	type: config.mw.wgNamespaceNumber === 6 ? 'ffd' : 'other' };

/* ========== Validate page suitability ========================================================= */ // Validate namespace var isCorrectNamespace = config.allowedNamespaces.includes(config.mw.wgNamespaceNumber); if ( !isCorrectNamespace ) { return; }

// If a portal, only make available if deleted var isPortal = config.mw.wgNamespaceNumber === 100; var notDeleted = config.mw.wgArticleId > 0; if ( isPortal && notDeleted ) { return; }

/* ========== Dependencies ====================================================================== */ mw.loader.using([	'mediawiki.util', 'mediawiki.api', 'mediawiki.Title',	'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows', 'jquery.ui',	'ext.gadget.libExtraUtil' ]).then(function {

/* ========== CSS Styles ======================================================================== * TODO: migrate to css subpage */ mw.util.addCSS(	[	// Task notices		'.xfdc-notices { width:80%; font-size:95%; padding-left:2.5em; }',		'.xfdc-notices > p { margin:0; line-height:1.1em; }',		'.xfdc-notice-error { color:#D00000; font-size:92% }',		'.xfdc-notice-warning { color:#9900A2; font-size:92% }',		'.xfdc-notice-error::before, .xfdc-notice-warning::before { content: " ["; }',		'.xfdc-notice-error::after, .xfdc-notice-warning::after  { content: "]"; }',		'.xfdc-task-waiting { color:#595959; }',		'.xfdc-task-started { color:#0000D0; }',		'.xfdc-task-done { color:#006800; }',		'.xfdc-task-skipped { color:#697000; }',		'.xfdc-task-aborted { color:#C00049; }',		'.xfdc-task-failed { color:#D00000; }',		// Preview of edit summary		'.xu-preview { background-color:#fafafa; border:1px dotted #777; '+			'margin-top: 0px; padding:0px 10px; font-size: 90%; width: 100%; }'	]	.join('\n') );

/* ========== Helper functions ================================================================== * TODO: these should probably be part of one or more script modules/libraries, which could be * loaded with mw.loader.getScript */ /** safeUnescape * Un-escapes some HTML tags (, , , , , and ); turns wikilinks * into real links. Ignores anyting within ... tags. * Input will first be escaped using mw.html.escape unless specified * @param {String} text * @param {Object} config Configuration options * @config {Boolean} noEscape - do not escape the input first * @returns {String} unescaped text */ var safeUnescape = function(text, config) { var path = 'https:' + mw.config.get('wgServer') + '/wiki/';

return ( config && config.noEscape && text || mw.html.escape(text) ) // Step 1: unescape tags .replace( 		/&lt;(\/?pre\s?\/?)&gt;/g,		'<$1>'	) // Step 2: replace piped wikilinks with real links (unless inside tags) .replace( 		/\[\[([^\|\]]*?)\|([^\|\]]*?)\]\](?![^<]*?<\/pre>)/g,		'$2'	) // Step 3: replace other wikilinks with real links (unless inside tags) .replace( 		/\[\[([^\|\]]+?)]\](?![^<]*?<\/pre>)/g,		'$1'	) // Step 4: unescape other tags:, , , , (unless inside tags) .replace(		/&lt;(\/?(?:br|p|ul|li|hr)\s?\/?)&gt;(?![^<]*?<\/pre>)/g,		'<$1>'	); };

/** multiButtonConfirm * @param {Object} config * @config {String} title Title for the dialogue * @config {String} message Message for the dialogue. HTML tags (except for, , , * , , and tags) are escaped; wikilinks are turned into real links. * @config {Array} actions Optional. Array of configuration objects for OO.ui.ActionWidget * . * If not specified, the default actions are 'accept' (with label 'OK') and 'reject' (with *  label 'Cancel'). * @config {String} size Symbolic name of the dialog size: small, medium, large, larger or full. * @return {Promise} action taken by user */ var multiButtonConfirm = function(config) { var dialogClosed = $.Deferred; // Wrap message in a HtmlSnippet to prevent escaping var htmlSnippetMessage = new OO.ui.HtmlSnippet(		safeUnescape(config.message)	);

var windowManager = new OO.ui.WindowManager; var messageDialog = new OO.ui.MessageDialog; $('body').append( windowManager.$element ); windowManager.addWindows( [ messageDialog ] ); windowManager.openWindow( messageDialog, {		'title': config.title,		'message': htmlSnippetMessage,		'actions': config.actions,		'size': config.size	} ); windowManager.on('closing', function(_win, promise) {		promise.then(function(data) { dialogClosed.resolve(data && data.action); windowManager.destroy; });	});

return dialogClosed.promise; };

var makeErrorMsg = function(code, jqxhr) { var details = ''; if ( code === 'http' && jqxhr.textStatus === 'error' ) { details = 'HTTP error ' + jqxhr.xhr.status; } else if ( code === 'http' ) { details = 'HTTP error: ' + jqxhr.textStatus; } else if ( code === 'ok-but-empty' ) { details = 'Error: Got an empty response from the server'; } else { details = 'API error: ' + code; }	return details; };

var arrayFromResponsePages = function(response) { return $.map(response.query.pages, function(page) { return page; }); };

/* ========== API =============================================================================== */ var API = new mw.Api( {	ajax: {		headers: { 			'Api-User-Agent': 'Xunlink/' + config.script.version + 				' ( https://en.wikipedia.org/wiki/User:Evad37/Xunlink )'		}	} } );

/* ========== Unlink backlinks ================================================================== */ /**unlinkBacklinks * * Copied from XFDcloser, with minimal changes. Such changes have the original code in comments * beginning `XFDC:` * * TODO: merge code, and import the same copy here and into XFDcloser * * @param self Object to hold some input date, and to recieve status messages */ var unlinkBacklinks = function(self) {

// Notify task is started self.setStatus('started'); var pageTitles = [config.mw.wgPageName]; // XFDC: self.discussion.getPageTitles(self.pages) var redirectTitles = []; // Ignore the following titles, and any of their subpages var ignoreTitleBases = [ 'Template:WPUnited States Article alerts', 'Template:Article alerts columns', 'Template:Did you know nominations' ];	var getBase = function(title) { return title.split('/')[0]; };	var blresults = []; var iuresults = []; //convert results (arrays of objects) to titles (arrays of strings), removing duplicates var flattenToTitles = function(results) { return results.reduce(			function(flatTitles, result) {				if ( result.redirlinks ) {					if ( !redirectTitles.includes(result.title)) {						redirectTitles.push(result.title);					}					return flatTitles.concat( result.redirlinks.reduce(							function(flatRedirLinks, redirLink) {								if ( flatTitles.includes(redirLink.title) || pageTitles.includes(redirLink.title) || ignoreTitleBases.includes(getBase(redirLink.title)) ) {									return flatRedirLinks;								} else {									return flatRedirLinks.concat(redirLink.title);								}							},							[]						) );				} else if ( result.redirect === '' || flatTitles.includes(result.title) || pageTitles.includes(result.title) || ignoreTitleBases.includes(getBase(result.title)) ) {					return flatTitles;				} else {					return flatTitles.concat(result.title);				}			},			[]		); };

var apiEditPage = function(pageTitle, newWikitext) { API.postWithToken( 'csrf', {			action: 'edit',			title: pageTitle,			text: newWikitext,			summary: self.editSummary + config.script.advert, /* XFDC:				'Removing link(s)' +				(( config.xfd.type === 'ffd' ) ? ' / file usage(s)' : '' ) +				': ' + self.discussion.getNomPageLink + ' closed as ' +				self.inputData.getResult + config.script.advert, */			minor: 1,			nocreate: 1		} ) .done( function {			self.track('unlink', true);		} ) .fail( function(code, jqxhr) {			self.track('unlink', false);			self.addApiError(code, jqxhr, [ 'Could not remove backlinks from ', extraJs.makeLink(pageTitle) ]);		} );	};

/**	 * @param {String} pageTitle * @param {String} wikitext * @returns {Promise(String)} updated wikitext, with any list items either removed or unlinked */	var checkListItems = function(pageTitle, wikitext) { // Find lines marked with, and the preceding section heading (if any) var toReview = /^(.*)$/m.exec(wikitext); if ( !toReview ) { // None found, no changes needed return $.Deferred.resolve(wikitext).promise; }		// Find the preceding heading, if any var precendingText = wikitext.split('')[0]; var allHeadings = precendingText.match(/^=+.+?=+$/gm); var heading = ( !allHeadings ) ? null : allHeadings[allHeadings.length - 1].replace(/(^=* *| *=*$)/g, ''); // Prompt user return multiButtonConfirm({			title: 'Review unlinked list item',			message: '\*?)\|([^\]]*?)\]\]/, '$2')						.replace(/\[\[([^\|\]]*?)\]\]/, '$1')					) + ']]' : ']]' ) +				': ' +				' ' + toReview[1] + ' ',			actions: [				{ label:'Keep item', action:'keep' },				{ label:'Remove item', action:'remove'}			],			size: 'medium'		}) .then(function(action) {			if ( action === 'keep' ) {				// Remove the void from the start of the line				wikitext = wikitext.replace(/^/m, );			} else {				// Remove the whole line				wikitext = wikitext.replace(/^.*\n?/m, );			}			// Iterate, in case there is more to be reviewed			return checkListItems(pageTitle, wikitext);		}); };	var processUnlinkPages = function(result) { if ( !result.query || !result.query.pages ) { // No results self.addApiError('result.query.pages not found', null, 'Could not read contents of pages; '+				'could not remove backlinks'); console.log('[XFDcloser] API error: result.query.pages not found... result ='); console.log(result); self.setStatus('failed'); return; }		// For each page, pass the wikitext through the unlink function var pages = arrayFromResponsePages(result); pages.reduce(			function(previous, page) {				return $.when(previous).then(function{ var oldWikitext = page.revisions[0]['*']; var newWikitext = extraJs.unlink(						oldWikitext,						pageTitles.concat(redirectTitles),						page.ns,						!!page.categories					); if ( oldWikitext !== newWikitext ) { var confirmedPromise = checkListItems(page.title, newWikitext); confirmedPromise.then(function(updatedWikitext) {							apiEditPage(page.title, updatedWikitext);						}); return confirmedPromise; } else { self.addWarning(['Skipped ',							extraJs.makeLink(page.title),							' (no direct links)'						]); self.track('unlink', false); return true; }				});			},			true); };

var apiReadFail = function(code, jqxhr) { self.addApiError(code, jqxhr, 'Could not read contents of pages; '+			'could not remove backlinks'); self.setStatus('failed'); };	var processResults = function { // Flatten results arrays if ( blresults.length !== 0 ) { blresults = flattenToTitles(blresults); }		if ( iuresults.length !== 0 ) { iuresults = flattenToTitles(iuresults); // Remove image usage titles that are also in backlikns results iuresults = iuresults.filter(function(t) { return $.inArray(t, blresults) === -1; }); }

// Check if, after flattening, there are still backlinks or image uses if ( blresults.length === 0 && iuresults.length === 0 ) { self.addWarning('none found'); self.setStatus('skipped'); return; }

// Ask user for confirmation var heading = 'Unlink backlinks'; if ( iuresults.length !== 0 ) { heading += '('; 			if ( blresults.length !== 0 ) {				heading += 'and ';			}			heading += 'file usage)'; }		heading += ':'; var para = ' All '+ (blresults.length + iuresults.length) + ' pages listed below may be '+ 'edited (unless backlinks are only present due to transclusion of a template). '+			' To process only some of these pages, use Twinkle\'s unlink tool instead. '+			' Use with caution, after reviewing the pages listed below. '+			'Note that the use of high speed, high volume editing software (such as this tool and '+			'Twinkle\'s unlink tool) is subject to the Bot policy\'s Assisted editing guidelines '+ ' ';		var list = ''; if ( blresults.length !== 0 ) { list += '' + blresults.join('') + '</li>'; }		if ( iuresults.length !== 0 ) { list += '<li>' + iuresults.join('</li><li>') + '</li>'; }		list += '<ul>'; multiButtonConfirm({			title: heading,			message: para + list,			actions: [				{ label: 'Cancel', flags: 'safe' },				{ label: 'Remove backlinks', action: 'accept', flags: 'progressive' }			],			size: 'medium'		}) .then(function(action) {			if ( action ) {				var unlinkTitles = iuresults.concat(blresults);				self.setupTracking('unlink', unlinkTitles.length);				self.showTrackingProgress = 'unlink';				// get wikitext of titles, check if disambig - in lots of 50 (max for Api)				for (var ii=0; ii<unlinkTitles.length; ii+=50) {					API.get( { action: 'query', titles: unlinkTitles.slice(ii, ii+49).join('|'), prop: 'categories|revisions', clcategories: 'Category:All disambiguation pages', rvprop: 'content', indexpageids: 1 } )					.done( processUnlinkPages )					.fail( apiReadFail );				}			} else {				self.addWarning('Cancelled by user');				self.setStatus('skipped');			}		}); };

// Queries var blParams = { list: 'backlinks', blfilterredir: 'nonredirects', bllimit: 'max', blnamespace: config.xfd.ns_unlink, blredirect: 1 };	var iuParams = { list: 'backlinks|imageusage', iutitle: '', iufilterredir: 'nonredirects', iulimit: 'max', iunamespace: config.xfd.ns_unlink, iuredirect: 1 };	var query = pageTitles.map(function(page) {		return $.extend( { action: 'query' }, blParams, { bltitle: page }, ( config.xfd.type === 'ffd' ) ? iuParams : null, ( config.xfd.type === 'ffd' ) ? { iutitle: page } : null );	});	// Variable for incrementing current query var qIndex = 0; // Function to do Api query var apiQuery = function(q) { API.get( q ) .done( processBacklinks ) .fail( function(code, jqxhr) {			self.addApiError(code, jqxhr, 'Could not retrieve backlinks');			self.setStatus('failed');			// Allow delete redirects task to begin			// XFDC: self.discussion.taskManager.dfd.ublQuery.resolve;		} ); };	// Process api callbacks var processBacklinks = function(result) { // Gather backlink results into array if ( result.query.backlinks ) { blresults = blresults.concat(result.query.backlinks); }		// Gather image usage results into array if ( result.query.imageusage ) { iuresults = iuresults.concat(result.query.imageusage); }		// Continue current query if needed if ( result.continue ) { apiQuery($.extend({}, query[qIndex], result.continue)); return; }		// Start next query, unless this is the final query qIndex++; if ( qIndex < query.length ) { apiQuery(query[qIndex]); return; }		// Allow delete redirects task to begin // XFDC: self.discussion.taskManager.dfd.ublQuery.resolve; // Check if any backlinks or image uses were found if ( blresults.length === 0 && iuresults.length === 0 ) { self.addWarning('none found'); self.setStatus('skipped'); return; }		// Process the results processResults; };	// Get started apiQuery(query[qIndex]); };

/* Task class for `self` object in unlinkBacklinks function * Very minimal copy of Task class from XFDcloser */ // Constructor var Task = function(conf) { this.description = 'Unlinking backlinks'; this.status = 'waiting'; this.errors = []; this.warnings = []; this.tracking = {}; this.editSummary = conf.editSummary; this.$notices = $(' ').attr('id','Xunlink-notices'); $('#mw-content-text').prepend(this.$notices); $(' ').text('Xunlink').insertBefore(this.$notices); $(' ').insertAfter(this.$notices); }; Task.prototype.setStatus = function(s) { this.status = s;	this.updateTaskNotices; }; Task.prototype.setupTracking = function(key, total, allDoneCallback, allSkippedCallback) { var self = this; if ( allDoneCallback == null && allSkippedCallback == null ) { allDoneCallback = function { this.setStatus('done'); }; allSkippedCallback = function { this.setStatus('skipped'); }; }	this.tracking[key] = { success: 0, skipped: 0, total: total, dfd: $.Deferred .done($.proxy(allDoneCallback, self)) .fail($.proxy(allSkippedCallback, self)) }; }; Task.prototype.track = function(key, success) { if ( success ) { this.tracking[key].success++; } else { this.tracking[key].skipped++; }

if ( key === this.showTrackingProgress ) { this.updateTaskNotices; // XFDC: this.updateStatus; }

if ( this.tracking[key].skipped === this.tracking[key].total ) { this.tracking[key].dfd.reject; } else if ( this.tracking[key].success + this.tracking[key].skipped === this.tracking[key].total ) { this.tracking[key].dfd.resolve; } }; Task.prototype.addError = function(e, critical) { // XFDC: var self = this; this.errors.push($(' ').addClass('xfdc-notice-error').append(e)); if ( critical ) { this.status = 'failed'; }	this.updateTaskNotices; // XFDC: this.discussion.taskManager.updateTaskNotices(self); }; Task.prototype.addWarning = function(w) { // XFDC: var self = this; this.warnings.push($(' ').addClass('xfdc-notice-warning').append(w)); this.updateTaskNotices; // XFDC: this.discussion.taskManager.updateTaskNotices(self); }; Task.prototype.addApiError = function(code, jqxhr, explanation, critical) { var self = this; self.addError([		makeErrorMsg(code, jqxhr),		' – ',		$(' ').append(explanation)	], !!critical); }; Task.prototype.getStatusText = function { var self = this; switch ( self.status ) { // Not yet started: case 'waiting': return 'Waiting...'; // In progress: case 'started': var $msg = $(' ').append(					$(' ').attr({ 'src':'//upload.wikimedia.org/wikipedia/commons/thumb/f/f8/Ajax-loader%282%29.gif/'+ '40px-Ajax-loader%282%29.gif', 'width':'20', 'height':'5' })			);			if ( self.showTrackingProgress ) { var counts = this.tracking[self.showTrackingProgress]; $msg.append(					$(' ')					.css('font-size', '88%')					.append( ' (' +						(counts.success + counts.skipped) +						'&thinsp;/&thinsp;' +						counts.total +						')' )				);			}			return $msg; // Finished: case 'done': return 'Done!'; case 'aborted': case 'failed': case 'skipped': return extraJs.toSentenceCase(self.status) + '.'; default: // unknown return ''; } }; // Based on XFDC's taskManager.prototype.updateTaskNotices Task.prototype.updateTaskNotices = function { var task = this; // XFDC: var self = this; var $notices = this.$notices; var note = $(' ') .addClass('xfdc-task-' + task.status) .addClass(task.name) .append(			$(' ').append(task.description),			': ',			$(' ').append(task.getStatusText),			$(' ').append(task.errors),			$(' ').append(task.warnings)		); $notices.empty.append(note); };

/* ========== Main dialog ======================================================================= */ // Make a subclass of ProcessDialog function MainDialog( config ) { MainDialog.super.call( this, config ); } OO.inheritClass( MainDialog, OO.ui.ProcessDialog );

// Specify a name for .addWindows MainDialog.static.name = 'mainDialog'; // Specify the static configurations: title and action set MainDialog.static.title = 'Xunlink'; MainDialog.static.actions = [ { 		flags: [ 'primary', 'progressive' ], label: 'Continue', action: 'continue' },	{ 		flags: 'safe', label: 'Cancel' } ];

// Customize the initialize function to add content and layouts: MainDialog.prototype.initialize = function { MainDialog.super.prototype.initialize.call( this ); this.panel = new OO.ui.PanelLayout( { 		padded: true, 		expanded: false 	} ); this.content = new OO.ui.FieldsetLayout;

this.summaryInput = new OO.ui.TextInputWidget; this.summaryPreview = new OO.ui.LabelWidget({classes: ['xu-preview']});

this.summaryInputField = new OO.ui.FieldLayout( this.summaryInput, { 		label: 'Enter the reason for link removal', 		align: 'top' 	} ); this.summaryPreviewField = new OO.ui.FieldLayout( this.summaryPreview, { 		label: 'Edit summary preview:', 		align: 'top' 	} );

this.content.addItems( [this.summaryInputField, this.summaryPreviewField] ); this.panel.$element.append( this.content.$element ); this.$body.append( this.panel.$element );

this.summaryInput.connect( this, { 'change': 'onSummaryInputChange' } ); };

// Specify any additional functionality required by the window (disable using an empty summary) MainDialog.prototype.onSummaryInputChange = function ( value ) { this.actions.setAbilities( {		continue: !!value.length	} ); var dialog = this; if ( !value.length ) { dialog.summaryPreviewField.toggle(false); dialog.updateSize; } else { API.get({			action: 'parse',			contentmodel: 'wikitext',			summary: 'Removing link(s): ' + value + config.script.advert,		}) .then(function(result) {			var $preview = $(' ').append(result.parse.parsedsummary['*']);			$preview.find('a').attr('target', '_blank');			dialog.summaryPreview.setLabel($preview);			dialog.summaryPreviewField.toggle(true);			dialog.updateSize;		}); } };

// Specify the dialog height (or don't to use the automatically generated height). MainDialog.prototype.getBodyHeight = function { // Note that "expanded: false" must be set in the panel's configuration for this to work. return this.panel.$element.outerHeight( true ); };

// Use getSetupProcess to set up the window with data passed to it at the time // of opening MainDialog.prototype.getSetupProcess = function ( data ) { data = data || {}; return MainDialog.super.prototype.getSetupProcess.call( this, data ) .next( function {		// Set up contents based on data		var dataSumamary = data.summary || '';		this.summaryInput.setValue( dataSumamary );		this.onSummaryInputChange(dataSumamary);	}, this ); };

// Specify processes to handle the actions. MainDialog.prototype.getActionProcess = function ( action ) { var dialog = this; if ( action === 'continue' ) { /* Create a new process to handle the action return new OO.ui.Process( function {			var task = new Task(this.summaryInput.getValue);			unlinkBacklinks(task);		}, this ); */		var task = new Task( {editSummary: 'Removing link(s): ' + this.summaryInput.getValue} ); dialog.close; task.updateTaskNotices; unlinkBacklinks(task); }	// Fallback to parent handler return MainDialog.super.prototype.getActionProcess.call( this, action ); };

// Use the getTeardownProcess method to perform actions whenever the dialog is closed. // This method provides access to data passed into the window's close method // or the window manager's closeWindow method. MainDialog.prototype.getTeardownProcess = function ( data ) { return MainDialog.super.prototype.getTeardownProcess.call( this, data ) .first( function {		// Perform any cleanup as needed		this.summaryInput.setValue("");	}, this ); };

// Create and append a window manager. var windowManager = new OO.ui.WindowManager; $( 'body' ).append( windowManager.$element );

// Create a new process dialog window. var mainDialog = new MainDialog;

// Add the window to window manager using the addWindows method. windowManager.addWindows( [ mainDialog ] );

/* ========== Portlet link ====================================================================== */ // handlePortletClick var handlePortletClick = function(e) { e.preventDefault; // Try to find the deletion log comment var comment = ''; var $commentEl = $('.mw-logline-delete').first.find('.comment').first; if ( $commentEl.length ) { var commentEl = $commentEl.get[0]; var children = commentEl.childNodes; for (var child of children) { var nodeName = 	child.nodeName; if (nodeName == 'A') { var target = child.href.replace(/^.*?\/wiki\//, '').replace(/_/g,' '); var label = child.textContent; var wikilink = ( target === label ) ?  + label +  :  + label + ; comment += wikilink; } else { comment += child.nodeValue; }		}		comment = comment.replace(' (XFDcloser)', ''); comment = comment.slice(1,-1); }	// Open the window! windowManager.openWindow( mainDialog, { summary: comment } ); };

var portletLink = mw.util.addPortletLink(	'p-cactions',	'#',	'Xunlink',	'ca-xu',	"Unlink this page's backlinks using Xunlink",	null,	"#ca-move" ); $(portletLink).on('click', handlePortletClick);

}); // End of dependencies loaded callback }); // End of page load callback //