Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* =====================================================================
 * Bharatpedia Gadget: AutoInterlink (v1.0)
 * Auto interlinker tool page (Bharatpedia:Auto interlinker).
 *
 * Enter an article title: the tool scans the wikitext for unlinked
 * terms that exist as Bharatpedia articles, shows a review list with
 * checkboxes, and publishes the chosen links as the logged-in user
 * when they click "Publish changes". "Discard" clears the results.
 *
 * All user-facing text is in English.
 * ===================================================================== */
( function () {
	'use strict';

	var TOOL_PAGE = 'Bharatpedia:Auto_interlinker';
	var MAX_CHECK = 100;
	var MAX_LINKS = 50;

	var STOP = ( 'a,an,the,and,or,but,nor,of,in,on,at,to,for,with,by,from,as,is,was,were,are,be,been,being,' +
		'has,have,had,do,does,did,will,would,can,could,shall,should,may,might,must,he,she,it,they,them,we,us,you,i,' +
		'me,my,mine,his,her,hers,its,their,theirs,our,ours,your,yours,this,that,these,those,there,here,where,when,' +
		'what,which,who,whom,whose,how,why,not,no,yes,so,such,than,then,too,very,just,also,well,however,although,' +
		'though,because,since,until,while,during,between,among,through,over,under,above,below,into,out,off,up,down,' +
		'about,against,after,before,per,via,etc,vs,mr,mrs,ms,dr,st,jr,sr,' +
		'january,february,march,april,june,july,august,september,october,november,december,' +
		'monday,tuesday,wednesday,thursday,friday,saturday,sunday,today,tomorrow,yesterday' ).split( ',' );

	function esc( s ) {
		return String( s == null ? '' : s ).replace( /[&<>"']/g, function ( c ) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
		} );
	}

	function mwNorm( s ) {
		s = String( s || '' ).replace( /_/g, ' ' ).replace( /\s+/g, ' ' ).trim();
		return s ? s.charAt( 0 ).toUpperCase() + s.slice( 1 ) : s;
	}

	// Regions of wikitext that must never get links inserted inside them.
	function getSafeRanges( text ) {
		// Returns [start,end) ranges of text SAFE for linking (excludes [[links]], <ref>, {{templates}})
		if ( !text ) return [];
		var n = text.length, i = 0;
		var unsafe = []; // [start,end) ranges to EXCLUDE
		var guard = 0;
		while ( i < n && guard < 100000 ) {
			guard++;
			// ==heading== (exclude entire line if it's a heading)
			if ( text.substr( i, 2 ) === '==' ) {
				var nl = text.indexOf( '\n', i );
				unsafe.push( [ i, nl === -1 ? n : nl ] );
				i = nl === -1 ? n : nl;
				continue;
			}
			// <!-- comment -->
			if ( text.substr( i, 4 ) === '<!--' ) {
				var e = text.indexOf( '-->', i + 4 );
				unsafe.push( [ i, e === -1 ? n : e + 3 ] );
				i = e === -1 ? n : e + 3;
				continue;
			}
			// [[link]]
			if ( text.substr( i, 2 ) === '[[' ) {
				var j = text.indexOf( ']]', i + 2 );
				unsafe.push( [ i, j === -1 ? n : j + 2 ] );
				i = j === -1 ? n : j + 2;
				continue;
			}
			// {{template}} with nesting
			if ( text.substr( i, 2 ) === '{{' ) {
				var depth = 1, k = i + 2;
				var g2 = 0;
				while ( k < n && depth > 0 && g2 < 50000 ) {
					g2++;
					if ( text.substr( k, 2 ) === '{{' ) { depth++; k += 2; }
					else if ( text.substr( k, 2 ) === '}}' ) { depth--; k += 2; }
					else k++;
				}
				unsafe.push( [ i, k ] );
				i = k;
				continue;
			}
			// <ref>...</ref> or <ref .../>
			if ( text.substr( i, 4 ).toLowerCase() === '<ref' ) {
				var gt = text.indexOf( '>', i + 4 );
				if ( gt === -1 ) { unsafe.push( [ i, n ] ); i = n; continue; }
				var tag = text.slice( i, gt + 1 );
				if ( tag.slice( -2 ) === '/>' ) { unsafe.push( [ i, gt + 1 ] ); i = gt + 1; continue; }
				var ce = text.toLowerCase().indexOf( '</ref>', gt + 1 );
				unsafe.push( [ i, ce === -1 ? n : ce + 6 ] );
				i = ce === -1 ? n : ce + 6;
				continue;
			}
			i++;
		}
		// Build safe ranges = complement of unsafe
		unsafe.sort( function ( a, b ) { return a[0] - b[0]; } );
		var safe = [], pos = 0, u;
		for ( u = 0; u < unsafe.length; u++ ) {
			if ( unsafe[u][0] > pos ) safe.push( [ pos, unsafe[u][0] ] );
			if ( unsafe[u][1] > pos ) pos = unsafe[u][1];
		}
		if ( pos < n ) safe.push( [ pos, n ] );
		return safe;
	}

	function collectExistingLinks( text ) {
		// Simplified safe version
		var set = {};
		if ( !text ) return set;
		try {
			var re = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, m;
			var count = 0;
			while ( ( m = re.exec( text ) ) !== null && count < 1000 ) {
				count++;
				set[ m[ 1 ].trim().toLowerCase().replace( /_/g, ' ' ) ] = true;
				if ( m[ 2 ] !== undefined ) set[ m[ 2 ].trim().toLowerCase().replace( /_/g, ' ' ) ] = true;
			}
		} catch ( e ) {}
		return set;
	}

	function stripPossessive( w ) {
		return w.replace( /'s$/i, '' ).replace( /'$/, '' );
	}

	function extractCandidates( text, safeRanges ) {
		// Simplified robust version - extracts capitalized phrases with REAL positions
		var cands = [], seen = {};
		var r, rs, seg, base;
		var re = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3})\b/g;
		var m, count = 0;
		for ( r = 0; r < safeRanges.length && count < 500; r++ ) {
			rs = safeRanges[ r ];
			seg = text.slice( rs[ 0 ], rs[ 1 ] );
			base = rs[ 0 ];
			re.lastIndex = 0;
			while ( ( m = re.exec( seg ) ) !== null && count < 500 ) {
				count++;
				var phrase = m[1];
				var absStart = base + m.index;
				var absEnd = absStart + phrase.length;
				var words = phrase.split(/\s+/);
				if ( words.length === 1 ) {
					var lw = words[0].toLowerCase();
					if ( STOP.indexOf( lw ) !== -1 ) continue;
				}
				var key = phrase.toLowerCase();
				if ( seen[ key ] ) continue;
				seen[ key ] = true;
				var pluralS = '';
				var term = phrase;
				var display = phrase;
				if ( /s$/i.test( term ) && term.length > 3 ) {
					var sing = term.replace( /s$/i, '' );
					if ( sing.length > 2 ) {
						pluralS = 's';
						term = sing;
						display = sing; // Display the singular, s goes outside
						key = term.toLowerCase();
					}
					if ( seen[ key ] ) continue;
					seen[ key ] = true;
				}
				cands.push( { term: term, pluralS: pluralS, display: display, start: absStart, end: absEnd, words: words.length } );
			}
		}
		return cands;
	}

	function greedyFilter( cands ) {
		// Simplified: just return first 100 to avoid O(n^2) hang
		// TODO: Implement proper overlap filtering later
		return cands.slice( 0, 100 );
	}

	function makeLink( display, finalTitle, pluralS ) {
		pluralS = pluralS || '';
		if ( display.toLowerCase() === finalTitle.toLowerCase() ) return '[[' + display + ']]' + pluralS;
		return '[[' + finalTitle + '|' + display + ']]' + pluralS;
	}

	function contextSnippet( text, c ) {
		var s = Math.max( 0, c.start - 60 ), e = Math.min( text.length, c.end + 60 );
		var before = text.slice( s, c.start ), mid = text.slice( c.start, c.end ), after = text.slice( c.end, e );
		before = before.replace( /\s+/g, ' ' );
		after = after.replace( /\s+/g, ' ' );
		return ( s > 0 ? '…' : '' ) + before + '[[' + mid + ']]' + after + ( e < text.length ? '…' : '' );
	}

	function buildTool() {
		var api = new mw.Api();

		var $root = $( '<div>' ).addClass( 'bp-ail' ).css( {
			border: '2px solid #36c', 'border-radius': '10px', padding: '18px',
			margin: '0 0 18px 0', background: '#f8f9fa', 'box-sizing': 'border-box'
		} );

		$root.append( $( '<h2>' ).text( 'Auto interlinker' )
			.css( { margin: '0 0 4px 0', 'font-size': '22px' } ) );
		$root.append( $( '<p>' )
			.text( 'Enter an article title. The tool finds terms that exist as Bharatpedia articles, lets you review each suggestion, and publishes the chosen links.' )
			.css( { margin: '0 0 12px 0', 'font-size': '13px', color: '#54595d' } ) );

		var $row = $( '<div>' ).css( { display: 'flex', gap: '8px', 'margin-bottom': '12px' } );
		var $titleInput = $( '<input>' ).attr( { type: 'text', placeholder: 'Article title (e.g. Hacktivist Vanguard)' } ).css( {
			flex: '1', padding: '10px 12px', 'font-size': '15px',
			border: '2px solid #36c', 'border-radius': '6px', 'box-sizing': 'border-box'
		} );
		var $scanBtn = $( '<button>' ).text( 'Scan' ).css( {
			padding: '10px 24px', 'font-size': '15px', cursor: 'pointer',
			background: '#36c', color: '#fff', border: 'none', 'border-radius': '6px', 'font-weight': 'bold'
		} );
		$row.append( $titleInput, $scanBtn );
		$root.append( $row );
		// Autocomplete dropdown for article titles
		var $suggest = $( '<div>' ).css( {
			position: 'absolute', background: '#fff', border: '1px solid #a2a9b1',
			'border-radius': '4px', 'box-shadow': '0 2px 8px rgba(0,0,0,0.15)',
			'z-index': '1000', 'max-height': '200px', overflow: 'auto', display: 'none',
			'font-size': '14px', 'min-width': '300px'
		} );
		$( 'body' ).append( $suggest );
		var suggestTimer = null;
		$titleInput.on( 'input', function () {
			var q = $titleInput.val().trim();
			if ( suggestTimer ) clearTimeout( suggestTimer );
			if ( q.length < 2 ) { $suggest.hide(); return; }
			suggestTimer = setTimeout( function () {
				api.get( { action: 'opensearch', search: q, limit: 8, namespace: 0 } ).then( function ( d ) {
					var titles = d[1] || [];
					$suggest.empty();
					if ( !titles.length ) { $suggest.hide(); return; }
					titles.forEach( function ( t ) {
						var $it = $( '<div>' ).text( t ).css( { padding: '8px 12px', cursor: 'pointer' } );
						$it.on( 'mouseenter', function () { $it.css( 'background', '#eaf3ff' ); } );
						$it.on( 'mouseleave', function () { $it.css( 'background', '#fff' ); } );
						$it.on( 'click', function () { $titleInput.val( t ); $suggest.hide(); $titleInput.focus(); } );
						$suggest.append( $it );
					} );
					var off = $titleInput.offset();
					$suggest.css( { top: off.top + $titleInput.outerHeight() + 4, left: off.left, width: $titleInput.outerWidth() } ).show();
				} ).catch( function () { $suggest.hide(); } );
			}, 300 );
		} );
		$( document ).on( 'click', function ( e ) {
			if ( !$( e.target ).closest( $suggest ).length && !$( e.target ).closest( $titleInput ).length ) $suggest.hide();
		} );

		var $msg = $( '<div>' ).css( { 'font-size': '13px', color: '#d33', 'margin-bottom': '8px' } ).hide();
		$root.append( $msg );
		var $status = $( '<div>' ).css( { 'font-size': '13px', color: '#54595d', 'margin-bottom': '8px' } ).hide();
		$root.append( $status );

		var $results = $( '<div>' ).hide();
		$root.append( $results );

		var state = {
			title: '', wikitext: '', timestamp: '', items: []
		};

		function showMsg( t ) { $msg.text( t ).show(); }
		function showStatus( t ) { $status.text( t ).show(); }
		function setScanBtn( busy ) {
			$scanBtn.prop( 'disabled', busy ).text( busy ? 'Scanning…' : 'Scan' );
		}

		function resolveTitles( lookups, onProgress ) {
			var map = {};
			var batches = [];
			for ( var i = 0; i < lookups.length; i += 100 ) {
				batches.push( lookups.slice( i, i + 100 ) );
			}
			var chain = $.Deferred().resolve().promise();
			batches.forEach( function ( batch, bi ) {
				chain = chain.then( function () {
					if ( onProgress ) onProgress( Math.min( ( bi + 1 ) * 100, lookups.length ), lookups.length );
					return api.get( {
						action: 'query', titles: batch.join( '|' ), prop: 'info', redirects: 1
					} ).then( function ( d ) {
						var byTitle = {}, p, id;
						var pages = ( d.query && d.query.pages ) || {};
						for ( id in pages ) {
							p = pages[ id ];
							byTitle[ mwNorm( p.title ) ] = p;
						}
						var redirs = {};
						( ( d.query && d.query.redirects ) || [] ).forEach( function ( r ) {
							redirs[ mwNorm( r.from ) ] = mwNorm( r.to );
						} );
						var norms = {};
						( ( d.query && d.query.normalized ) || [] ).forEach( function ( x ) {
							norms[ mwNorm( x.from ) ] = mwNorm( x.to );
						} );
						batch.forEach( function ( t ) {
							var nt = mwNorm( t );
							if ( norms[ nt ] ) nt = norms[ nt ];
							var seenR = {}, guard = 0;
							while ( redirs[ nt ] && !seenR[ nt ] && guard < 10 ) { seenR[ nt ] = true; nt = redirs[ nt ]; guard++; }
							var pg = byTitle[ nt ];
							if ( pg && pg.missing === undefined ) {
								map[ mwNorm( t ) ] = { exists: true, final: pg.title };
							} else {
								map[ mwNorm( t ) ] = { exists: false, final: null };
							}
						} );
					}, function () {
						batch.forEach( function ( t ) {
							map[ mwNorm( t ) ] = { exists: false, final: null };
						} );
					} );
				} ).then( function () {
					var d = $.Deferred();
					setTimeout( d.resolve, 200 );
					return d.promise();
				} );
			} );
			return chain.then( function () { return map; } );
		}

		function scan() {
			var title = $.trim( $titleInput.val() );
			$msg.hide(); $results.hide().empty();
			if ( !title ) {
				showMsg( 'Please enter an article title.' );
				return;
			}
			setScanBtn( true );
			showStatus( 'Fetching article…' );
			api.get( {
				action: 'query', prop: 'revisions', titles: title,
				rvprop: 'content|timestamp', rvslots: 'main', formatversion: 2
			} ).then( function ( d ) {
				var pages = d.query.pages;
				var page = pages[ 0 ];
				if ( page.missing ) {
					throw new Error( 'Article not found: ' + title );
				}
				state.title = page.title;
				state.wikitext = page.revisions[ 0 ].slots.main.content;
				state.timestamp = page.revisions[ 0 ].timestamp;
				state.normTitle = mwNorm( page.title ).toLowerCase().replace( / /g, '_' );

				showStatus( 'Extracting candidate terms…' );
				var safeRanges = getSafeRanges( state.wikitext );
				var existing = collectExistingLinks( state.wikitext );
				var cands = greedyFilter( extractCandidates( state.wikitext, safeRanges ) );
				cands = cands.filter( function ( c ) {
					return !existing[ c.term.toLowerCase() ] && !existing[ c.display.toLowerCase() ];
				} );
				if ( !cands.length ) {
					showStatus( 'No linkable terms found in ' + state.title + '.' );
					return;
				}
				var toCheck = cands.slice( 0, MAX_CHECK );
				showStatus( 'Checking ' + toCheck.length + ' term(s)…' );
				return resolveTitles(
					toCheck.map( function ( c ) { return c.term; } ),
					function ( done, total ) { showStatus( 'Checking terms… (' + done + '/' + total + ')' ); }
				).then( function ( map ) {
					var items = [];
					toCheck.forEach( function ( c ) {
						var info = map[ mwNorm( c.term ) ];
						if ( !info || !info.exists ) return;
						if ( mwNorm( info.final ).toLowerCase().replace( / /g, '_' ) === state.normTitle ) return;
						items.push( {
							cand: c, finalTitle: info.final,
							link: makeLink( c.display, info.final, c.pluralS ),
							checked: null
						} );
						if ( items.length >= MAX_LINKS ) return;
					} );
					state.items = items;
					if ( !items.length ) {
						showStatus( 'No new links to suggest for ' + state.title + '.' );
						return;
					}
					renderResults();
				} );
			} ).catch( function ( err ) {
				showMsg( ( err && err.message ) || 'Something went wrong.' );
				$status.hide();
			} ).always( function () {
				setScanBtn( false );
			} );
		}

		function renderResults() {
			$status.hide();
			var items = state.items;

			var $head = $( '<p>' ).html(
				'Found <b>' + items.length + '</b> suggested link(s) in <b>' +
				esc( state.title ) + '</b>. Review and publish, or discard.'
			).css( { 'font-size': '14px' } );
			$results.append( $head );

			var $togRow = $( '<div>' ).css( { 'margin-bottom': '8px', 'font-size': '13px' } );
			var $selAll = $( '<button>' ).text( 'Select all' ).css( btnSmallCss() );
			var $selNone = $( '<button>' ).text( 'Select none' ).css( btnSmallCss() );
			$selAll.on( 'click', function () { toggleAll( true ); } );
			$selNone.on( 'click', function () { toggleAll( false ); } );
			$togRow.append( $selAll, ' ', $selNone );
			$results.append( $togRow );

			var $list = $( '<div>' ).css( {
				'max-height': '340px', overflow: 'auto', border: '1px solid #c8ccd1',
				'border-radius': '6px', background: '#fff', padding: '8px', 'margin-bottom': '12px'
			} );
			items.forEach( function ( it ) {
				var $item = $( '<div>' ).css( {
					display: 'flex', gap: '8px', 'align-items': 'flex-start',
					padding: '8px', 'border-bottom': '1px solid #eaecf0', 'font-size': '13px'
				} );
				var $cb = $( '<input>' ).attr( { type: 'checkbox', checked: true } )
					.css( { 'margin-top': '3px' } );
				it.checked = $cb;
				var $txt = $( '<div>' ).css( { flex: '1' } );
				var $termLine = $( '<div>' );
				$termLine.append( $( '<b>' ).text( it.cand.display ) );
				$termLine.append( document.createTextNode( ' → ' ) );
				$termLine.append( $( '<a>' )
					.attr( { href: mw.util.getUrl( it.finalTitle ), target: '_blank', rel: 'noopener' } )
					.text( it.finalTitle ) );
				$txt.append( $termLine );
				var ctx = contextSnippet( state.wikitext, it.cand );
				var $ctx = $( '<div>' ).css( { color: '#72777d', 'font-size': '12px', 'margin-top': '2px' } );
				$ctx.append( document.createTextNode( ctx.replace( /\[\[/g, '' ).replace( /\]\]/g, '' ) ) );
				$txt.append( $ctx );
				$item.append( $cb, $txt );
				$list.append( $item );
			} );
			$results.append( $list );

			var $btnRow = $( '<div>' ).css( { display: 'flex', gap: '8px', 'align-items': 'center' } );
			var $pubBtn = $( '<button>' ).text( 'Publish' ).css( {
				padding: '10px 24px', 'font-size': '15px', cursor: 'pointer',
				background: '#14866d', color: '#fff', border: 'none',
				'border-radius': '6px', 'font-weight': 'bold'
			} );
			$pubBtn.on( 'click', publish );
			var $discardBtn = $( '<button>' ).text( 'Discard' ).css( {
				padding: '10px 24px', 'font-size': '15px', cursor: 'pointer',
				background: '#72777d', color: '#fff', border: 'none',
				'border-radius': '6px', 'font-weight': 'bold'
			} );
			$discardBtn.on( 'click', function () {
				state.items = [];
				$results.hide().empty();
				showStatus( 'Discarded. Enter an article title to scan again.' );
			} );
			$btnRow.append( $pubBtn, $discardBtn );
			$results.append( $btnRow );
			$results.append( $( '<p>' ).text( 'The edit will be saved under your own account. Please review the list above first.' )
				.css( { 'font-size': '12px', color: '#54595d', 'margin-top': '8px' } ) );

			$results.show();
		}

		function btnSmallCss() {
			return {
				padding: '4px 12px', 'font-size': '12px', cursor: 'pointer',
				background: '#eaecf0', color: '#202122', border: '1px solid #c8ccd1',
				'border-radius': '4px'
			};
		}

		function toggleAll( v ) {
			state.items.forEach( function ( it ) {
				if ( it.checked ) it.checked.prop( 'checked', v );
			} );
		}

		function publish() {
			if ( mw.config.get( 'wgUserName' ) === null ) {
				showMsg( 'Please log in to publish changes.' );
				return;
			}
			var chosen = state.items.filter( function ( it ) { return it.checked && it.checked.is( ':checked' ); } );
			if ( !chosen.length ) {
				showMsg( 'No links selected.' );
				return;
			}
			// Build replacements from end to start so offsets stay valid
			var reps = chosen.map( function ( it ) {
				return { start: it.cand.start, end: it.cand.end, text: it.link };
			} );
			reps.sort( function ( a, b ) { return b.start - a.start; } );
			var text = state.wikitext;
			reps.forEach( function ( r ) {
				text = text.slice( 0, r.start ) + r.text + text.slice( r.end );
			} );

			showStatus( 'Publishing…' );
			api.postWithToken( 'csrf', {
				action: 'edit',
				title: state.title,
				text: text,
				basetimestamp: state.timestamp,
				summary: 'Add internal wikilinks via Auto interlinker tool',
				assert: 'user'
			} ).then( function ( d ) {
				var rev = d.edit && d.edit.newrevid;
				$results.empty().append(
					$( '<p>' ).html(
						'<b>Published!</b> ' + reps.length + ' link(s) added to <b>' +
						esc( state.title ) + '</b>.' +
						( rev ? ' <a href="' + mw.util.getUrl( 'Special:Diff/' + rev ) + '">View changes</a>' : '' )
					).css( { 'font-size': '14px', color: '#14866d' } )
				);
				$status.hide();
			} ).fail( function ( code, err ) {
				$status.hide();
				showMsg( 'Publish failed: ' + ( err && err.message ? err.message : code ) );
			} );
		}

		$scanBtn.on( 'click', scan );
		$titleInput.on( 'keydown', function ( e ) { if ( e.key === 'Enter' ) scan(); } );

		return $root;
	}

	function init() {
		try {
			var pageName = mw.config.get( 'wgPageName' );
			if ( pageName !== TOOL_PAGE && pageName.indexOf( TOOL_PAGE + '/' ) !== 0 ) {
				return;
			}
			var $mount = $( '#autointerlink-root' );
			if ( !$mount.length ) {
				return;
			}
			var $tool = buildTool();
			$mount.replaceWith( $tool );
		} catch ( e ) {
			if ( window.console && console.error ) {
				console.error( 'AutoInterlink init failed: ' + ( e && e.message ) );
			}
		}
	}

	function waitForDeps( callback, attempts ) {
		attempts = attempts || 0;
		try {
			if ( typeof $ !== 'undefined' && typeof mw !== 'undefined' &&
				mw.Api && mw.util && document.getElementById( 'autointerlink-root' ) ) {
				callback();
				return;
			}
		} catch ( e ) {}
		if ( attempts < 100 ) {
			setTimeout( function () { waitForDeps( callback, attempts + 1 ); }, 100 );
		} else {
			try { callback(); } catch ( e ) {}
		}
	}
	waitForDeps( function () {
		try {
			if ( document.readyState === 'loading' ) {
				document.addEventListener( 'DOMContentLoaded', init );
			} else {
				init();
			}
		} catch ( e ) {}
	} );
}() );
// cache-bust 1505