User:SoledadKabocha/linkclassifier2.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/* Adapted from [[User:Anomie/linkclassifier.js]], to replace an older fork at [[User:SoledadKabocha/linkclassifier.js]]

In addition to this script's own documentation page, see [[User:SoledadKabocha/linkclassifier]]

No linkback required; talk-page post preferred instead
*/

// flag to distinguish this from Anomie's original linkclassifier; SHOULD be read but not modified by other scripts
window.LinkClassifierSupportsFuncChain = true;

// Any setting not explicitly included in these type checks or mentioned on the old doc should be considered undocumented
if ( typeof window.LinkClassifierOnDemand               !== 'boolean' ) window.LinkClassifierOnDemand               = false;

if ( typeof window.LinkClassifierIgnoreRedirectedFrom   !== 'boolean' ) window.LinkClassifierIgnoreRedirectedFrom   = false;
if ( typeof window.LinkClassifierNoApihighlimits        !== 'boolean' ) window.LinkClassifierNoApihighlimits        = false;
if ( typeof window.LinkClassifierRedirTitleAppend       !== 'boolean' ) window.LinkClassifierRedirTitleAppend       = false;
if ( typeof window.LinkClassifierSkipDraftDetection     !== 'boolean' ) window.LinkClassifierSkipDraftDetection     = false;
if ( typeof window.LinkClassifierUninterruptible        !== 'boolean' ) window.LinkClassifierUninterruptible        = false;

// These two are intentional behavior differences from Anomie's original linkclassifier
if ( typeof window.LinkClassifierAddPortletLinkManually !== 'boolean' ) window.LinkClassifierAddPortletLinkManually = false;
// XXX: should probably be renamed/aliased now that we actually call mw.notify...
if ( typeof window.LinkClassifierJsMessageOnError       !== 'boolean' ) window.LinkClassifierJsMessageOnError       = true ;

if ( typeof window.LinkClassifierAutohideFatalErrors    !== 'boolean' ) window.LinkClassifierAutohideFatalErrors    = false;

if ( typeof window.LinkClassifierRetryOnError           !== 'boolean' )  window.LinkClassifierRetryOnError          = true ;

var LinkClassifier = {
	/* This object maps classes to the categories for which to apply them. Values may be an array of strings or a regex. */
	cats: {
		deletion: [
			'Category:All articles proposed for deletion',
			'Category:All books proposed for deletion',
			'Category:All categories for discussion',
			'Category:All disputed non-free Wikipedia files',
			'Category:All files proposed for deletion',
			'Category:All orphaned non-free use Wikipedia files',
			// RFD has been moved to a separate deletion-rfd class, below
			'Category:All replaceable non-free use Wikipedia files',
			'Category:All Wikipedia files with no non-free use rationale',
			'Category:All Wikipedia files with unknown copyright status',
			'Category:All Wikipedia files with unknown source',
			'Category:Articles for deletion',
			'Category:Articles for deletion using wrong syntax',
			'Category:Articles on deletion review',
			'Category:Articles to be merged after an Articles for deletion discussion',
			'Category:Candidates for speedy deletion',
			'Category:Candidates for undeletion',
			'Category:Categories for conversion',
			'Category:Categories for deletion',
			'Category:Categories for listifying',
			'Category:Categories for merging',
			'Category:Categories for renaming',
			'Category:Categories for speedy renaming',
			'Category:Categories to be listified then deleted',
			//'Category:Copy to Wiktionary',
			'Category:Duplicate or hardcoded templates awaiting deletion',
			'Category:Items pending OTRS confirmation of permission for over 30 days',
			'Category:Miscellaneous pages for deletion',
			'Category:Templates for deletion',
			'Category:Wikipedia files for discussion'
		].sort(),
		'deletion-rfd': [
			'Category:All redirects for discussion'
		].sort(),
		disambiguation: [
			'Category:All disambiguation pages'
		].sort(),
		'set-index': [
			'Category:All set index articles'
		].sort(),
		'featured-content': [
			'Category:Featured articles',
			'Category:Featured lists',
			'Category:Featured pictures',
			'Category:Featured sounds',
			'Category:Featured videos',
			'Category:Featured portals'
		].sort(),
		'good-content': [
			'Category:Good articles'
		].sort(),
		'soft-redirect': [
			'Category:Wikipedia soft redirects',
			'Category:Wikipedia interwiki soft redirects',
			'Category:Wikipedia soft redirected project pages',
			'Category:Protected soft redirects',
			'Category:Wikipedia soft redirected talk pages',
			'Category:Wikipedia soft redirected templates',
			'Category:User soft redirects',
			'Category:Redirects to Wikiquote',
			'Category:Deprecated shortcuts',
			'Category:Redirects to Wiktionary' //XXX: create separate soft-redirect-wiktionary class?
		].sort(),
		'soft-redirect-cats': [
			'Category:Wikipedia soft redirected categories'
		].sort(),
		'spoken-articles': [
			'Category:Spoken articles'
		].sort(),
		stubcls: /^Category:.* stubs$/,
		'nonfree-media': [
			'Category:All non-free media'
		].sort(),
		'no-attrib-reqd-media': [
			//'Category:Cc-zero images',
			'Category:CC-zero files',
			//'Category:All user-created public domain images',
			'Category:All User-created public domain files',
			//'Category:Author died more than 100 years ago public domain images',
			'Category:Author died more than 100 years ago public domain files',
			//'Category:Author died more than 70 years ago public domain images',
			'Category:Author died more than 70 years ago public domain files',
			//'Category:Copyright holder released public domain images',
			'Category:Copyright holder released public domain files',
			'Category:PD OpenClipart',
			'Category:PD US no notice',
			'Category:PD US not renewed',
			'Category:PD chem',
			'Category:PD other reasons',
			'Category:PD script',
			'Category:PD tag needs updating',
			'Category:PD-link',
			'Category:Pre 1978 without copyright notice US public domain images', // still current name
			'Category:Public domain art',
			//'Category:Public domain images',
			'Category:Public domain files',
			//'Category:Public domain images ineligible for copyright',
			'Category:Public domain files ineligible for copyright',
			'Category:Public domain images of currency', // still current name
			'Category:Public domain images of fonts', // still current name
			'Category:Public domain music images', // still current name
			'Category:Files copyrighted by the Wikimedia Foundation'
		].sort(),
		unprintworthy: [
			'Category:Unprintworthy redirects',
			'Category:Middle-earth redirects from redundant titles'
		].sort(),
		'unprintworthy-shortcut': [ // XXX: verbose name, if non-mainspace redirects are never printworthy
			'Category:Redirects from shortcuts'
		].sort(),
		'incorrect-title': [ // should cover all "highly-unprintworthy" Rcats for which "pages that use this link should be updated"
			'Category:Redirects from incorrect disambiguation',
			'Category:Redirects from incorrect names',
			'Category:Redirects from miscapitalisations',
			'Category:Redirects from misspellings'
		].sort(),
		printworthy: [
			'Category:Printworthy redirects'
		].sort(),
		invalidisbn: [
			'Category:Articles with invalid ISBNs',
			'Category:Articles with invalid ISSNs',
			'Category:Pages with ISBN errors' // applied by Citation Style 1 module, so also in cs1luaerror
		].sort(),
		missingisbn: [
			'Category:Articles lacking ISBNs'
		].sort(),
		'blp-sources':/^Category:BLP articles lacking sources( from |$)/,
		'blp-unreferenced': [
			'Category:All unreferenced BLPs',
		].sort(),
		'blp-sourceimdb':/^Category:Articles sourced (only )?by IMDb( from |$)/,
		'unclear-notability': [
			'Category:All articles with topics of unclear notability'
		].sort(),
		'need-coord': [
			'Category:All articles needing coordinates',
			'Category:Pages with malformed coordinate tags'
		].sort(),
		'cleanup-translation': [
			'Category:Wikipedia articles needing cleanup after translation'
		].sort(),
		deadend: [
			'Category:All dead-end pages',
			'Category:All articles with too few wikilinks',
			'Category:All articles covered by WikiProject Wikify'
		].sort(),
		'expert-attention': [
			'Category:All articles needing expert attention'
		].sort(),
		'user-special-block': [
			'Category:Wikipedia contact role accounts',
			'Category:Wikipedia maintenance scripts'
		].sort(),
		'user-compromised-block': [ // not 100% useful because the user(talk) pages may not exist
			'Category:Compromised accounts'
		].sort(),
		'user-altaccount': [
			'Category:Wikipedia doppelganger accounts'
		].sort(),
		'redirect-possibilities': [
			'Category:Redirects with possibilities',
			'Category:Redirects from albums',
			'Category:Redirects from books',
			'Category:Redirects from brand names',
			'Category:Redirects from EPs',
			'Category:Redirects from films',
			'Category:Redirects from former names',
			'Category:Redirects to list entries',
			//'Category:Redirects from people', // doesn't currently contain articles
			'Category:Redirects from phrases',
			'Category:Redirects from products',
			'Category:Redirects from school articles',
			'Category:Redirects from songs',
			'Category:Redirects from writers',
			//'Category:Redirects from transport routes', // doesn't currently contain articles
			'Category:Comics redirects with possibilities',
			'Category:Redirects to decade',
			'Category:Middle-earth redirects with possibilities',
			'Category:Redirects from birth names',
			'Category:Redirects from alternative characters',
			'Category:Comics redirects to lists',
			'Category:Comics redirects from related words',
			'Category:Comics redirects to sections',
			'Category:Middle-earth redirects to lists',
			'Category:Redirects from birth names',
			'Category:Redirects from historic names',
			'Category:Redirects from maiden names',
			'Category:Redirects from married names',
			'Category:Middle-earth redirects from former names',
			'Category:Characters redirects to lists',
			'Category:Episode redirects to lists', // XXX: Subcats not done!
			'Category:Redirected episode articles',
			'Category:Fiction-based redirects to list entries',
			'Category:Redirected characters articles',
			'Category:Aqua Teen Hunger Force characters redirects to lists',
			'Category:Courage the Cowardly Dog characters redirects to lists',
			'Category:I Am Weasel characters redirects',
			'Category:SpongeBob SquarePants characters redirects to lists',
			'Category:The Venture Bros. characters redirects to lists',
			'Category:Fictional character redirects to lists', // XXX: Subcats not done!
			//'Category:Fictional element redirects to lists', // doesn't currently contain articles
			'Category:Redirected fictional element articles',
			'Category:Redirects from individual people',
			'Category:Redirects from multiple people',
			'Category:Redirects from members',
			'Category:Redirects from quotations',
			'Category:Redirects from slogans',
			'Category:Redirects from highway routes',
			'Category:Redirects from London bus routes',
			'Category:Redirects from New York City area bus routes',
			'Category:Redirects from railroad names with ampersands',
			'Category:Redirects from highway in region' // has a "without possibilities" subcat...
		].sort(),
		'redirect-section': [ // Anomie said not 100% useful because a human has to manually add category
			'Category:Redirects to sections',
			'Category:Redirects to embedded anchors'
		].sort(),
		'redirect-listentry': [
			'Category:Redirects to list entries', // also in -possibilities, I know
			'Category:Redirects from list topics'
		].sort(),
		'redirect-incompletedab': [
			'Category:Redirects from incomplete disambiguations'
		].sort(),
		chemobot:/^Category:(\w+boxes which contain changes to (watched|verified) fields|Articles (containing unverified chemical infoboxes|with changed (\w+ identifier|CASNo)))$/,
		citeerror:/^Category:(Pages with (\w+ reference names|incorrect ref formatting|missing references list|reference errors)|Articles with incorrect citation syntax)$/,
		npovdispute: [
			'Category:All NPOV disputes'
		].sort(),
		accuracydispute: [
			'Category:All accuracy disputes'
		].sort(),
		copyedit: [
			'Category:All articles needing copy edit'
		].sort(),
		deadextlink: [
			'Category:All articles with dead external links',
			'Category:All articles with broken or outdated citations'
		].sort(),
		outofdate:/^Category:(Articles with obsolete information|Wikipedia articles in need of updating)( from |$)/,
		nofootnotes: [
			'Category:All articles lacking in-text citations'
		].sort(),
		'disambiguation-cleanup':/^Category:(Disambiguation pages (in need of (being split|cleanup)|to be converted to broad concept articles)|Incomplete disambiguation)( from |$)/,
		'redirect-crossnamespace': [
			'Category:Cross-namespace redirects',
			'Category:Redirects to category space',
			'Category:Redirects to help namespace',
			'Category:Redirects to the main namespace',
			'Category:Redirects to portal space',
			'Category:Redirects to template from non-template namespace',
			'Category:Redirects to project space',
			'Category:Redirects to user namespace',
			'Category:Redirects to talk pages'
		].sort(),
		'systemicbias-geo':/^Category:((Articles with (disproportional|limited)|Vague or ambiguous) geographic scope( from |$)|.+-centric$)/,
		'systemicbias-time':/^Category:Articles (slanted towards recent events( from |$)|lacking historical information$)/,
		'systemicbias-other':/^Category:Articles needing more viewpoints( from |$)/,
		tempundelete:[
			'Category:Candidates for undeletion'
		].sort(),
		'promotional-tone': [
			'Category:All articles with a promotional tone'
		].sort(),
		'retracted-pub': [
			'Category:Articles citing retracted publications'
		].sort(),
		rewrite:/^Category:Wikipedia articles needing rewrite( from |$)/,
		subscriptiononly: [
			'Category:Pages containing links to subscription only content'
		].sort(),
		povcheck:/^Category:Articles needing POV-check( from |$)/,
		// XXX: Temporary hack; although the Harv and Sfn templates use Lua modules, they should be a separate category
		cs1luaerror:/^Category:(Pages (using (web )?citations with|with archiveurl cit.+ errors$|with citations ((hav|us)ing|lacking titles$)|with [A-Z]+ errors$|with empty citations$)|CS1( errors| maint|:)|Harv and Sfn)/,
		'transwiki-cleanup': [
			'Category:Transwiki cleanup'
		].sort(),
		'template-error': [
			'Category:Pages containing omitted template arguments',
			'Category:Pages where expansion depth is exceeded',
			'Category:Pages where node count is exceeded',
			//'Category:Pages where node-count is exceeded', //no longer used in current MW versions (which includes all WMF wikis)
			'Category:Pages where template include size is exceeded',
			'Category:Pages with template loops',
			'Category:UserLinks transclusions with errors', //XXX: either separate this from the non-Wikipedia-specific stuff above, or generalize it all into a regex
			'Category:Pages with incorrectly transcluded templates' //probably specific to enwikipedia (XXX: check on this)
		].sort(),
		'disambiguation-cats': [
			'Category:Disambiguation categories'
		].sort(),
		luaerror: [
			'Category:Pages with script errors'
		].sort(),
		'unprintworthy-assoc': [ //formerly called redirect-grammaticalnumber
			'Category:Redirects from plurals',
			'Category:Redirects to plurals',
			'Category:Redirects from other capitalisations'
		].sort(),
		// not "-cats" because the members are not themselves categories. XXX: Needs better name!
		'uncategorized-cat':/^Category:(All )?[Uu]ncategorized (from|pages|stubs)/,
		'category-maint': [
			'Category:Category needs checking'
		].sort(),
		'noconsensus-proj': [
			'Category:Wikipedia failed proposals',
			'Category:Inactive project pages',
			'Category:Inactive WikiProjects',
			'Category:Defunct WikiProjects'
		].sort(),
		'deprecation-general':/^Category:Pages using deprecated/,
		'failed-verification':/^Category:A(ll a)?rticles with failed verification/,
		'avoid2r-fix': [
			'Category:Avoided double redirects/error',
			'Category:Avoided double redirects to be updated'
		].sort(),
		'authoritycontrol-bad':/^Category:Articles with faulty .+ identifiers/,
		'unincorporated-community':/^Category:Unincorporated communities/
	},

	/* This object maps page props to CSS classes for which to apply them. Values may be an array of strings or a function returning such. */
	props: {
		disambiguation: [
			'disambiguation'
		]
	},

	/* This regex matches page titles to be marked as intentional links to disambiguation pages */
	intentionaldab: / \(disambiguation\)$/,

	/* Was it run already? */
	wasRun: false,

	/* Was it rerun already due to an error? */
	wasRerunDueToError: false,

	/* Do we need to let the user know that multiple messages were grouped together? */
	warningShown: false,

	oldOnbeforeunload:    function ( ) { },
	unloadSafetyTimeout:  0,
	unloadHandlerRemoved: false,

	// runs at global scope because it is called from setTimeout
	removeUnloadHandlerIfNeeded: function ( ) {
		if ( !window.LinkClassifierUninterruptible || LinkClassifier.unloadHandlerRemoved ) return;

		window.onbeforeunload = LinkClassifier.oldOnbeforeunload;
		if ( LinkClassifier.unloadSafetyTimeout !== 0 ) {
			clearTimeout( LinkClassifier.unloadSafetyTimeout );

			if ( typeof window.LinkClassifierStatusSel === 'string' ) {
				var statusElement = $( window.LinkClassifierStatusSel );
				statusElement.removeClass( 'linkclassifier-unload-added' );
				statusElement.addClass(    'linkclassifier-unload-removed' );
			}
		}

		LinkClassifier.unloadHandlerRemoved = true; // should this be inside the "if"?
	},

	initUnloadHandlerIfNeeded: function ( ) {
		if ( window.LinkClassifierUninterruptible ) {
			if ( ( mw.config.get( 'wgAction' ) === 'edit' || mw.config.get( 'wgAction' ) === 'submit' ) && $.compareObject( mw.user.options.get( [ 'skin' ] ), { skin: 'vector' } ) ) {
				LinkClassifier.logWarning( 'Ignoring user setting of uninterruptible mode to avoid warnings saving edits in the Vector skin' );
				window.LinkClassifierUninterruptible = false; // still needed?
				return;
			}
			else {
				LinkClassifier.oldOnbeforeunload = window.onbeforeunload;
				window.onbeforeunload = function (evt) {
					var message = 'LinkClassifier is still waiting for API responses. (This message shouldn\'t be visible on a modern browser)';
					if (evt === undefined) {
						evt = window.event;
					}
					if (evt) {
						evt.returnValue = message;
					}
					return message;
				}
			}
			// need to check whether the timeout already exists?
			LinkClassifier.unloadSafetyTimeout = setTimeout( LinkClassifier.removeUnloadHandlerIfNeeded, 59000 );

			if ( typeof window.LinkClassifierStatusSel === 'string' ) {
				$( window.LinkClassifierStatusSel ).addClass( 'linkclassifier-unload-added' );
			}
		}
	},

	/*
		coin flip between calling rerun() and reloading the page because we don't want to loop infinitely when the network goes down (hence the wasRerunDueToError variable) but do want to allow more than 2 attempts if necessary
		also because waiting for pending requests to finish is tricky, so we pick a delay value arbitrarily (including some random noise), but kicking off a rerun() probably causes Bad Things to happen if requests are indeed pending, so we do need to reload in some cases

		don't try to reload the page for actions such as 'edit', 'submit', or 'move'

		Note that no attempt is made to persist wasRerunDueToError across reloads; we rely on the total number of attempts to follow an exponential distribution roughly.
	*/
	retryOrReload: function ( ) {
		LinkClassifier.removeUnloadHandlerIfNeeded( );

		var zzAction = mw.config.get( 'wgAction' );

		if ( ( Math.random( ) < 0.5 ) && ( zzAction === 'view' || zzAction === 'history' ) ) {
			window.location.href = window.location.href;
		}
		else if ( !LinkClassifier.wasRerunDueToError ) {
			LinkClassifier.wasRerunDueToError = true;
			LinkClassifier.rerun( );
		}
	},

	logWarning: function ( errStr ) {
		if ( window.console !== undefined && typeof window.console.error  === 'function' ) {
			window.console.error( errStr );
		}
		if ( window.LinkClassifierJsMessageOnError ) {
			var notifyHtml = '<span class="linkclassifier-error linkclassifier-warn"><span class="linkclassifier-error-prefix">[linkclassifier';
			if ( LinkClassifier.warningShown ) {
				notifyHtml += ': Please check browser console for previous messages';
			}
			notifyHtml += ']</span> <span class="linkclassifier-error-content">' + errStr + '</span></span>';
			mw.notify(
				$( notifyHtml ),
				{ tag: 'linkclassifier-warn' }
			);
			LinkClassifier.warningShown = true;
		}
	},

	//create a timeout that accepts arguments and executes in the scope of object LinkClassifier
	//adapted from https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#Possible_solutions
	lcSetTimeout: function ( vCallback, nDelay /*, argumentToPass1, argumentToPass2, etc. */ ) {
		var __nativeST__ = window.setTimeout, oThis = this, aArgs = Array.prototype.slice.call( arguments, 2 );
		return __nativeST__( vCallback instanceof Function ? function ( ) {
			vCallback.apply( oThis, aArgs );
		} : vCallback, nDelay );
	},

	logAndAbort: function ( errStr ) {
		// delay time is a WIP and should possibly scale with backend response time and database lag
		if ( window.LinkClassifierRetryOnError ) {
			LinkClassifier.lcSetTimeout( LinkClassifier.retryOrReload, 5000 + Math.floor( Math.random( ) * 2001 ) );
		}

		// No need to use console here because the browser's dev tools will pick up the Error
		if ( window.LinkClassifierJsMessageOnError ) {
			var notifyHtml = '<span class="linkclassifier-error linkclassifier-fatal"><span class="linkclassifier-error-prefix">[linkclassifier';
			notifyHtml += ']</span> <span class="linkclassifier-error-content">' + errStr + '</span></span>';
			// A common error is that an impatient user has interrupted our AJAX by navigating away from the page.
			// Delay the notification so it won't be visible in such a case.
			setTimeout(
				mw.notify,
				800, // arbitrary, needs tuning
				$( notifyHtml ),
				{ tag: 'linkclassifier-fatal', autoHide: window.LinkClassifierAutohideFatalErrors }
			);
		}
		throw new Error( errStr );
	},

	/* Count requests so we know when we are done */
	requestsOutstanding: 0,

	/* Track whether we have already sent any AJAX requests on the current run, so the slowdown feature can avoid delaying the first request */
	isFirstRequest: true,

	chainedFuncCalledAlready: false,

	forceCallChainedFunc: function ( ) {
		// XXX: add (temporary/optional?) debug prints to all early exit conditions that call this
		LinkClassifier.removeUnloadHandlerIfNeeded( );
		// XXX: should use instanceof Function?
		if ( !LinkClassifier.chainedFuncCalledAlready && typeof window.LinkClassifierChainedFunc === 'function' ) {
			LinkClassifier.chainedFuncCalledAlready = true;
			if ( window.LinkClassifierChainedFuncArg === undefined ) {
				LinkClassifierChainedFunc();
			}
			else {
				LinkClassifierChainedFunc( window.LinkClassifierChainedFuncArg );
			}
		}
	},

	/*
		titleListsEmptied was intended to ensure that if one batch of links finishes before the next one starts,
		we do not call the chained func unless all batches have been processed.
		It does not accomplish that; it only guarantees that the last batch has been *sent*.
		Since AJAX is asynchronous, we may only have completed up through the second-to-last batch.
		We mitigate/work around this with a time delay.

		processLinks (inside classifyChildren, below) sets titleListsEmptied to true.
	*/
	titleListsEmptied: false,

	// runs at global scope because it is called from setTimeout
	onAjaxCompleteSub: function ( ) {
		if ( --LinkClassifier.requestsOutstanding <= 0 && LinkClassifier.titleListsEmptied ) {
			LinkClassifier.applyUncategorizedClasses( );
			LinkClassifier.forceCallChainedFunc( );
			if ( typeof window.LinkClassifierStatusSel === 'string' ) {
				var statusElement = $( window.LinkClassifierStatusSel );
				statusElement.removeClass( 'linkclassifier-started' );
				// TODO: better way of checking this that doesn't involve DOM operations
				// that is, onAjaxError & co. need to set a flag
				if ( !( statusElement.hasClass( 'linkclassifier-aborted' ) ) ) {
					statusElement.addClass( 'linkclassifier-finished' );
				}
			}
		}
	},

	validateSlowDownSetting: function ( ) {
		return	( typeof window.LinkClassifierSlowDownMin === 'number' ) &&
			( window.LinkClassifierSlowDownMin === Math.ceil( window.LinkClassifierSlowDownMin ) ) &&
			( window.LinkClassifierSlowDownMin >= 0 ) &&
			( typeof window.LinkClassifierSlowDownMax === 'number' ) &&
			( window.LinkClassifierSlowDownMax === Math.ceil( window.LinkClassifierSlowDownMax ) ) &&
			( window.LinkClassifierSlowDownMax > 0 ) &&
			( window.LinkClassifierSlowDownMax >= window.LinkClassifierSlowDownMin );
	},

	onAjaxComplete: function ( xhr, sts /* both unused */ ) {
		if ( LinkClassifier.requestsOutstanding <= 0 ) {
			// Tried to decrement request count below zero
			// "Multiple copies are running" when I preview edits to this script itself;
			// otherwise something is really wrong.

			if ( typeof window.LinkClassifierStatusSel === 'string' ) {
				var statusElement = $( window.LinkClassifierStatusSel );
				statusElement.removeClass( 'linkclassifier-started' );
				statusElement.addClass(    'linkclassifier-aborted' );
			}

			LinkClassifier.logWarning( 'Finished more API requests than started (maybe multiple copies are running)?!' );
		}

		// To avoid wrongly decreasing requestsOutstanding to 0,
		// allow reasonable time for the next request (if any)
		// to be sent before we do final cleanup.

		// 1ms above the minimum sane value of 4ms used by most browsers
		var hackTime = 5;

		if ( LinkClassifier.validateSlowDownSetting( ) ) {
			hackTime = Math.max(
				Math.ceil((window.LinkClassifierSlowDownMin+window.LinkClassifierSlowDownMax)/2),
				5
			);
		}

		setTimeout( LinkClassifier.onAjaxCompleteSub, hackTime );
	},

	onAjaxError: function ( xhr /* unused */, textStatus, errorThrown ) {
		if ( typeof window.LinkClassifierStatusSel === 'string' ) {
			var statusElement = $( window.LinkClassifierStatusSel );
			statusElement.removeClass( 'linkclassifier-started' );
			statusElement.addClass( 'linkclassifier-aborted' );
		}
		LinkClassifier.forceCallChainedFunc( );

		//LinkClassifier.ajaxErrorSeen = true;

		var errStr = 'AJAX error (' + LinkClassifier.requestsOutstanding + ' requests were in progress): ' + textStatus + ' ' + errorThrown;
		LinkClassifier.logAndAbort( errStr );
	},

	/* XXX: These should probably take a string argument to allow the message to specify the call site */
	onBadResponse: function ( r ) {
		// this would ordinarily be done in logAndAbort
		if ( window.LinkClassifierRetryOnError ) {
			LinkClassifier.lcSetTimeout( LinkClassifier.retryOrReload, 5000 + Math.floor( Math.random( ) * 2001 ) );
		}

		if ( typeof window.LinkClassifierStatusSel === 'string' ) {
			var statusElement = $( window.LinkClassifierStatusSel );
			statusElement.removeClass( 'linkclassifier-started' );
			statusElement.addClass(    'linkclassifier-aborted' );
		}
		LinkClassifier.forceCallChainedFunc( );

		// XXX: should use logAndAbort, but it doesn't quite fit the pattern (what to do with r?)
		if ( window.LinkClassifierJsMessageOnError ) {
			var notifyHtml = '<span class="linkclassifier-error linkclassifier-fatal"><span class="linkclassifier-error-prefix">[linkclassifier';
			notifyHtml += ']</span> <span class="linkclassifier-error-content">Bad response</span></span>';
			mw.notify(
				$( notifyHtml ),
				{ tag: 'linkclassifier-fatal', autoHide: false }
			);
		}
		if ( !window.console || typeof window.console.error !== 'function' ) {
			throw new Error( 'Bad response' );
		}
		window.console.error( 'Bad response', r );
	},

	onMissingBodyContent: function ( ) {
		LinkClassifier.forceCallChainedFunc( );

		LinkClassifier.logAndAbort( 'Huh? No body content?' );
	},

	makeAjaxRequest: function( dat, rdat, cb ) {
		LinkClassifier.requestsOutstanding++;

		$.ajax( {
			url:      mw.util.wikiScript('api'),
			dataType: 'json',
			type:     'POST',
			data:     dat,
			rawdata:  rdat,
			success:  cb,
			error:    LinkClassifier.onAjaxError,
			complete: LinkClassifier.onAjaxComplete
		} );

		LinkClassifier.isFirstRequest = false;
	},

	makeAjaxRequestWithDelay: function( dat, rdat, cb ) {
		var slowdownValid = ( !LinkClassifier.isFirstRequest ) && LinkClassifier.validateSlowDownSetting( );

		if ( !slowdownValid ) {
			LinkClassifier.makeAjaxRequest( dat, rdat, cb );
		}
		else {
			var realDelay = window.LinkClassifierSlowDownMin;
			if ( realDelay !== window.LinkClassifierSlowDownMax ) {
				realDelay += Math.floor( Math.random( ) * ( window.LinkClassifierSlowDownMax - window.LinkClassifierSlowDownMin + 1 ) );
			}

			// Minimum timeout honored by browser is 4ms; if we chose less, do some rounding
			// https://developer.mozilla.org/en-US/docs/Web/API/WindowTimers/setTimeout#Nested_timeouts_forced_to_%3E4ms
			if ( realDelay <= 1 ) {
				realDelay = 0;
			}
			else if ( realDelay <= 3 ) {
				realDelay = 4;
			}

			if ( realDelay === 0 ) {
				LinkClassifier.makeAjaxRequest( dat, rdat, cb );
			}
			else {
				LinkClassifier.lcSetTimeout( LinkClassifier.makeAjaxRequest, realDelay, dat, rdat, cb );
			}
		}
	},

	unencodeAnchor: function ( myAnchor ) {
		return myAnchor.replace(/_/g,' ');
	},

	getDefaultDOMNode: function ( mayThrowError ) {
		var bodyNodeId = window.LinkClassifierIgnoreRedirectedFrom ? 'mw-content-text' : 'bodyContent';
		var node = document.getElementById( 'wikiPreview' );
		if ( !node ) {
			node = document.getElementById( bodyNodeId );
		}
		if ( !node && mayThrowError ) {
			LinkClassifier.onMissingBodyContent( );
		}
		return node;
	},

	callback: function ( r ) {
		var i, j, k, k2, v, node, alist, q, prefix, seen, cls,
			redir = {},
			redirlist = [],
			cats = {},
			missing = {},
			classes = {};

		if ( !r.query ) {
			LinkClassifier.onBadResponse( r ); // throws Error...
			return;                            // so not really needed?
		}
		if ( r['query-continue'] ) {
			q = this.rawdata;
			for ( k in r['query-continue'] ) {
				for ( k2 in r['query-continue'][k] ) {
					q[k2] = r['query-continue'][k][k2];
				}
			}
			LinkClassifier.makeAjaxRequestWithDelay( q, this.rawdata, LinkClassifier.callback );
		}
		r = r.query;

		node = LinkClassifier.getDefaultDOMNode( true );
		alist = node.getElementsByTagName( 'A' );
		if ( alist.length === 0 ) {
			return;
		}

		if ( r.redirects ) {
			for ( i = r.redirects.length - 1; i >= 0; i-- ) {
				redir[r.redirects[i].from] = r.redirects[i].to;
				redirlist.push( r.redirects[i].from );
			}
		}
		if ( redirlist.length > 0 ) {
			q = {
				format: 'json',
				action: 'query',
				titles: redirlist.join( '|' ),
				prop: 'categories|info',
				inprop: 'protection',
				cllimit: 'max',
				rawcontinue: 1
			};
			LinkClassifier.makeAjaxRequestWithDelay( q, q, LinkClassifier.callback );
		}

		prefix = this.rawdata.redirects ? '' : 'redir-';
		if ( r.pages ) {
			for ( i in r.pages ) {
				classes[r.pages[i].title] = [];
				missing[r.pages[i].title] = r.pages[i].missing !== undefined;
				// XXX: Can this ever be an empty list, as opposed to undefined?
				if ( r.pages[i].categories !== undefined ) {
					cats[r.pages[i].title] = r.pages[i].categories.map( function ( a ) {
						return a.title;
					} ).sort();
					LinkClassifier.isTitleCategorized[r.pages[i].title] = true;
				}
				else if ( LinkClassifier.isTitleCategorized[r.pages[i].title] === undefined ) {
					// false here is only a tentative decision; we may change it to true later.
					// We may sometimes miss categories and only pick them up later
					// after query continuation, but unless another user removes categories
					// from a page in the middle of our execution, we will never mistakenly declare
					// the page to have categories when it is really uncategorized.
					LinkClassifier.isTitleCategorized[r.pages[i].title] = false;
				}
				if ( r.pages[i].pageprops ) {
					for ( k in r.pages[i].pageprops ) {
						if ( !LinkClassifier.props[k] ) {
							continue;
						}
						v = LinkClassifier.props[k];
						if ( typeof v === 'function' ) {
							v = v( r.pages[i].pageprops[k], k, r.pages[i].title );
						}
						classes[r.pages[i].title].push.apply( classes[r.pages[i].title], v );
					}
				}
				if ( r.pages[i].protection ) {
					seen = {};
					for ( j = r.pages[i].protection.length - 1; j >= 0; j-- ) {
						cls = prefix + 'protection-' + r.pages[i].protection[j].type + '-' + r.pages[i].protection[j].level;
						if ( !seen[cls] ) {
							seen[cls] = 1;
							classes[r.pages[i].title].push( cls );
						}
						if ( r.pages[i].protection[j].expiry === 'infinity' ) {
							cls += '-indef';
							if ( !seen[cls] ) {
								seen[cls] = 1;
								classes[r.pages[i].title].push( cls );
							}
						}
					}
				}
				if ( r.pages[i].flagged ) {
					if ( r.pages[i].lastrevid !== r.pages[i].flagged.stable_revid ) {
						classes[r.pages[i].title].push( 'needs-review' );
					}
				}
			}
		}
		Array.prototype.forEach.call( alist, function ( a ) {
			var cns, cls, m, i, j, pageCats, matchCats,
				$a = $( a );

			if ( a.wikipage === undefined ) {
				return;
			}
			if ( redir[a.wikipage] ) {
				$a.addClass( 'redirect' );
				a.wikipage = redir[a.wikipage];
				a.title = ( window.LinkClassifierRedirTitleAppend ? a.title + '\n⤷' + a.wikipage : a.wikipage );
				cns = mw.config.get( 'wgCanonicalNamespace' );
				if ( a.wikipage === ( cns ? cns + ':' : '' ) + mw.config.get( 'wgTitle' ) ) {
					$a.addClass( 'self-redirect' );
				}
				if ( missing[a.wikipage] ) {
					$a.addClass( 'broken-redirect' );
				}
			}
			m = a.href.match( /#.*/ );
			
			if ( m && m[0].substr( 0, 10 ) !== '#cite_note' ) {
				a.title = a.title.replace( /#.*/, '' ) +
					LinkClassifier.unencodeAnchor( m[0] );
			}
			if ( LinkClassifier.intentionaldab.test( a.origwikipage ) ) {
				$a.addClass( 'intentional-disambiguation' );
			}
			if ( classes[a.wikipage] ) {
				for ( j = classes[a.wikipage].length - 1; j >= 0; j-- ) {
					$a.addClass( classes[a.wikipage][j] );
				}
			}
			if ( a.wikipage !== a.origwikipage && classes[a.origwikipage] ) {
				for ( j = classes[a.origwikipage].length - 1; j >= 0; j-- ) {
					$a.addClass( classes[a.origwikipage][j] );
				}
			}

			pageCats = [];
			if ( cats[a.wikipage] ) {
				pageCats = pageCats.concat( cats[a.wikipage] );

				LinkClassifier.isTitleCategorized[a.wikipage] = true;
			}
			if ( a.wikipage !== a.origwikipage && cats[a.origwikipage] ) {
				pageCats = pageCats.concat( cats[a.origwikipage] );

				LinkClassifier.isTitleCategorized[a.origwikipage] = true;
			}
			if ( pageCats.length > 0 ) {
				pageCats = pageCats.sort();
				for ( cls in LinkClassifier.cats ) {
					i = pageCats.length - 1;
					matchCats = LinkClassifier.cats[cls];
					if ( matchCats instanceof RegExp ) {
						while ( i >= 0 ) {
							if ( matchCats.test( pageCats[i] ) ) {
								$a.addClass( cls );
								break;
							}
							i--;
						}
					} else {
						j = matchCats.length - 1;
						while ( i >= 0 && j >= 0 ) {
							if ( pageCats[i] === matchCats[j] ) {
								$a.addClass( cls );
								break;
							}
							if ( pageCats[i] > matchCats[j] ) {
								--i;
							} else {
								--j;
							}
						}
					}
				}
			}
		} );
	},

	draftsCallback: function ( r ) {
		if ( window.LinkClassifierSkipDraftDetection ) return;

		var i, node, alist,
			found = {};

		if ( !r.query ) {
			LinkClassifier.onBadResponse( r ); // throws Error...
			return;                            // ...so not really needed?
		}
		r = r.query;

		node = LinkClassifier.getDefaultDOMNode( true );
		alist = node.getElementsByTagName( 'A' );
		if ( alist.length === 0 ) {
			return; // XXX: should forceCallChainedFunc( ) ?
		}

		if ( r.pages ) {
			for ( i in r.pages ) {
				found[r.pages[i].title] = r.pages[i].missing === undefined;
			}
		}
		Array.prototype.forEach.call( alist, function ( a ) {
			if ( a.wikipage !== undefined && found['Draft:' + a.origwikipage] ) {
				$( a ).addClass( 'has-draft' );
			}
		} );
	},

	isTitleCategorized: { },

	applyUncategorizedClasses: function ( ) {
		var node, alist;

		node = LinkClassifier.getDefaultDOMNode( true );
		alist = node.getElementsByTagName( 'A' );
		if ( alist.length === 0 ) {
			return;
		}

		Array.prototype.forEach.call( alist, function ( a ) {
			var $a = $( a );
			if ( a.wikipage !== undefined && !( $a.hasClass( 'new' ) ) ) {
				if ( LinkClassifier.isTitleCategorized[a.wikipage] === false ) {
					$a.addClass( 'uncategorized' );
				}
				if ( a.origwikipage !== a.wikipage && LinkClassifier.isTitleCategorized[a.origwikipage] === false ) {
					$a.addClass( 'redir-uncategorized' );
				}
			}
		} );
	},

	getPageName: function ( url ) {
		var t, m = url.match( /\/wiki\/([^?#]+)/ );
		if ( !m ) {
			m = url.match( /\/w\/index.php\?(?:.*&)?title=([^&#]+)/ );
		}
		if ( !m ) {
			return '';
		}
		t = decodeURIComponent( m[1] ).replace( /_/g, ' ' );
		if ( t.substr( 0, 6 ) === 'Image:' ) {
			t = 'File:' + t.substr( 6 );
		}
		if ( t.substr( 0, 11 ) === 'Image talk:' ) {
			t = 'File talk:' + t.substr( 11 );
		}
		if ( t.substr( 0, 8 ) === 'Special:' ) {
			t = '';
		}
		return t;
	},

	classifyChildren: function ( node ) {
		var realNode;
		if ( !node ) {
			LinkClassifier.logWarning(
				'classifyChildren needs a valid DOM node; falling back to default. ' +
				'Scripts other than linkclassifier itself should call onDemand instead.'
			);
			realNode = LinkClassifier.getDefaultDOMNode( true );
		}
		else {
			realNode = node;
		}

		mw.loader.using( [ 'mediawiki.util', 'mediawiki.user' ], function () {
			if ( typeof window.LinkClassifierStatusSel === 'string' ) {
				$( window.LinkClassifierStatusSel ).addClass( 'linkclassifier-started' );
			}

			var alist, titles, draftTitles, re, self, props, i, k;

			LinkClassifier.wasRun = true;
			alist = realNode.getElementsByTagName( 'A' );
			if ( !alist.length ) {
				return;
			}
			self = LinkClassifier.getPageName( location.href );
			titles = Array.prototype.map.call( alist, function ( a ) {
				a.wikipage = '';
				if ( /(^|\s)(external|extiw)(\s|$)/.test( a.className ) ) {
					return '';
				}
				if ( !/(^|\s)(image|mw-file-description)(\s|$)/.test( a.className ) ) {
					a.className += ' nonimage';
				}
				a.wikipage = LinkClassifier.getPageName( a.href );
				if ( a.wikipage === self ) {
					a.wikipage = '';
				}
				a.origwikipage = a.wikipage;
				return a.wikipage;
			} ).sort().filter( function ( e, i, a ) {
				return e !== '' && ( i === 0 || a[i - 1] !== e );
			} );

			re = [];
			for ( k in mw.config.get( 'wgNamespaceIds' ) ) {
				if ( k !== '' ) {
					re.push( k.replace( /_/g, ' ' ) );
				}
			}
			re = new RegExp( '^(' + re.join( '|' ) + '):', 'i' );
			draftTitles = [];
			for ( i = titles.length - 1; i >= 0; i-- ) {
				if ( !re.test( titles[i] ) ) {
					draftTitles.push( 'Draft:' + titles[i] )
				}
			}

			props = [];
			for ( k in LinkClassifier.props ) {
				props.push( k );
			}

			function processLinks( limit ) {
				if ( titles.length === 0 ) {
					// This is where we detect linklessness for completely empty pages (0byte wikitext).
					// This isn't an error
					LinkClassifier.titleListsEmptied = true;
					LinkClassifier.forceCallChainedFunc( );
					return;
				}
				var q;
				while ( titles.length > 0 ) {
					q = {
						format: 'json',
						action: 'query',
						titles: titles.splice( 0, limit ).join( '|' ),
						prop: 'categories|pageprops|info|flagged',
						redirects: 1,
						cllimit: 'max',
						inprop: 'protection',
						rawcontinue: 1
					};
					if ( props.length <= limit ) {
						q.ppprop = props.join( '|' );
					}
					LinkClassifier.makeAjaxRequestWithDelay( q, q, LinkClassifier.callback );
				}

				while ( !window.LinkClassifierSkipDraftDetection && draftTitles.length > 0 ) {
					q = {
						format: 'json',
						action: 'query',
						titles: draftTitles.splice( 0, limit ).join( '|' ),
						rawcontinue: 1
					};
					LinkClassifier.makeAjaxRequestWithDelay( q, q, LinkClassifier.draftsCallback );
				}

				LinkClassifier.titleListsEmptied = true;
			}

			if ( window.LinkClassifierNoApihighlimits || titles.length <= 100 ) {
				// Not worth querying the API to see if the user has apihighlimits
				// (or user has manually declared that they lack the right)
				processLinks( 50 );
			} else {
				// Note mw.user.getRights queries the API
				mw.user.getRights( function ( rights ) {
					processLinks( ( rights.indexOf( 'apihighlimits' ) >= 0 ) ? 500 : 50 );
				} );
			}
		} );
	},

	onLoad: function () {
		if ( window.LinkClassifierOnDemand ) {
			return;
		}
		if ( window.AJAXPreview && typeof window.AJAXPreview.AddOnLoadHook === 'function' ) {
			// FIXME: This code path was intended for a MW version now outdated
			// and will not execute as-is on WMF wikis
			window.AJAXPreview.AddOnLoadHook( LinkClassifier.classifyChildren );
		}
		LinkClassifier.onDemand();
	},

	onDemand: function () {
		mw.hook( 'LinkClassifier' ).fire( this );

		// We need to reset these in case we have already run
		LinkClassifier.isFirstRequest    = true;
		LinkClassifier.titleListsEmptied = false;
		LinkClassifier.warningShown      = false;

		// Categorization status of linked pages may or may not have changed between run and rerun
		// XXX: Consider having a (configurable?) time threshold below which we don't clear this
		// XXX: ...but doing so won't improve performance, since we still need API requests. Hmm...
		LinkClassifier.isTitleCategorized = { };

		// XXX: belongs in classifyChildren or perhaps processLinks?
		LinkClassifier.chainedFuncCalledAlready = false;

		LinkClassifier.requestsOutstanding = 0;

		var node = LinkClassifier.getDefaultDOMNode( false );
		if ( node ) {
			LinkClassifier.initUnloadHandlerIfNeeded();
			LinkClassifier.classifyChildren( node );
		}
		if ( window.LinkClassifierOnDemand ) {
			// TODO: instead replace it with a link that calls rerun?
			var myPortletLink = $('#ca-linkclassifier');
			if ( myPortletLink && myPortletLink.length > 0 ) myPortletLink.remove( );
		}
	},

	rerun: function () {
		// we may have called rerun() after an error, either automatically or manually
		// so remove any added class that would indicate an error
		if ( typeof window.LinkClassifierStatusSel === 'string' ) {
			$( window.LinkClassifierStatusSel ).removeClass( 'linkclassifier-aborted' );
		}

		if ( LinkClassifier.wasRun ) {
			LinkClassifier.onDemand();
		}
		else {
			LinkClassifier.logWarning( 'Attempted to rerun linkclassifier, but it was not previously run on this page; did you mean to call onDemand?' );
		}
	}
};

if ( window.LinkClassifierOnDemand ) {
	if ( !window.LinkClassifierAddPortletLinkManually ) {
		// ...for compatibility with my previous version
		var lcOnDemandLoc = ( typeof window.LinkClassifierOnDemandLoc  === 'string' ? window.LinkClassifierOnDemandLoc  : 'p-cactions' );
		var lcOnDemandTxt = ( typeof window.LinkClassifierOnDemandText === 'string' ? window.LinkClassifierOnDemandText : 'linkclassifier' );

		var plnk = mw.util.addPortletLink(
			lcOnDemandLoc,
			'#lc-' + Math.random( ),
			lcOnDemandTxt,
			'ca-linkclassifier'
		);

		$( plnk ).click( function ( e ) {
			e.preventDefault( );
			LinkClassifier.onDemand( );
		});
	}
}
else {
	$( LinkClassifier.onLoad );
}