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 = 200;
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ 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 ) {
var skip = [], n = text.length, i = 0, j, e, m, tag, close, gt;
while ( i < n ) {
if ( text.indexOf( '<!--', i ) === i ) {
j = text.indexOf( '-->', i + 4 );
e = j === -1 ? n : j + 3;
skip.push( [ i, e ] ); i = e;
} else if ( text.indexOf( '[[', i ) === i ) {
var depth = 0; j = i;
while ( j < n ) {
if ( text.indexOf( '[[', j ) === j ) { depth++; j += 2; }
else if ( text.indexOf( ']]', j ) === j ) { depth--; j += 2; if ( depth === 0 ) break; }
else j++;
}
skip.push( [ i, j ] ); i = j;
} else if ( text.indexOf( '{{', i ) === i ) {
var d2 = 0; j = i;
while ( j < n ) {
if ( text.indexOf( '{{', j ) === j ) { d2++; j += 2; }
else if ( text.indexOf( '}}', j ) === j ) { d2--; j += 2; if ( d2 === 0 ) break; }
else j++;
}
skip.push( [ i, j ] ); i = j;
} else if ( /^<ref[\s>\/]/i.test( text.slice( i, i + 6 ) ) ) {
gt = text.indexOf( '>', i );
if ( gt === -1 ) { skip.push( [ i, n ] ); i = n; }
else if ( text[ gt - 1 ] === '/' ) { skip.push( [ i, gt + 1 ] ); i = gt + 1; }
else {
close = text.toLowerCase().indexOf( '</ref>', gt );
e = close === -1 ? n : close + 6;
skip.push( [ i, e ] ); i = e;
}
} else if ( /^<(references|gallery|poem|nowiki|pre|source|syntaxhighlight)[\s>\/]/i.test( text.slice( i, i + 22 ) ) ) {
m = text.slice( i ).match( /^<([a-zA-Z]+)/ );
tag = m ? m[ 1 ].toLowerCase() : 'ref';
close = text.toLowerCase().indexOf( '</' + tag + '>', i );
e = close === -1 ? n : close + tag.length + 3;
skip.push( [ i, e ] ); i = e;
} else if ( text[ i ] === '[' ) {
j = text.indexOf( ']', i + 1 );
e = j === -1 ? i + 1 : j + 1;
skip.push( [ i, e ] ); i = e;
} else if ( /^https?:\/\//i.test( text.slice( i, i + 8 ) ) ) {
j = i;
while ( j < n && !/\s/.test( text[ j ] ) ) j++;
skip.push( [ i, j ] ); i = j;
} else {
i++;
}
}
skip.sort( function ( a, b ) { return a[ 0 ] - b[ 0 ]; } );
var safe = [], pos = 0, s;
for ( var k = 0; k < skip.length; k++ ) {
s = skip[ k ];
if ( s[ 0 ] > pos ) safe.push( [ pos, s[ 0 ] ] );
if ( s[ 1 ] > pos ) pos = s[ 1 ];
}
if ( pos < n ) safe.push( [ pos, n ] );
return safe;
}
function collectExistingLinks( text ) {
var set = {}, re = /\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, m;
while ( ( m = re.exec( text ) ) !== null ) {
set[ m[ 1 ].trim().toLowerCase().replace( /_/g, ' ' ) ] = true;
if ( m[ 2 ] !== undefined ) set[ m[ 2 ].trim().toLowerCase().replace( /_/g, ' ' ) ] = true;
}
return set;
}
function stripPossessive( w ) {
return w.replace( /'s$/i, '' ).replace( /'$/, '' );
}
function extractCandidates( text, safeRanges ) {
var cands = [], seen = {};
var wordRe = /[\p{L}][\p{L}\p{N}'-]*/gu;
var r, rs, seg, lines, li, line, abs, words, m, i, j, runLen, len, s, win, lows, parts, lookup, key;
for ( r = 0; r < safeRanges.length; r++ ) {
rs = safeRanges[ r ];
seg = text.slice( rs[ 0 ], rs[ 1 ] );
lines = seg.split( '\n' );
abs = rs[ 0 ];
for ( li = 0; li < lines.length; li++ ) {
line = lines[ li ];
if ( !/^\s*=+.*=+\s*$/.test( line ) ) {
words = [];
wordRe.lastIndex = 0;
while ( ( m = wordRe.exec( line ) ) !== null ) {
words.push( { w: m[ 0 ], start: abs + m.index, end: abs + m.index + m[ 0 ].length } );
}
i = 0;
while ( i < words.length ) {
if ( /^\p{Lu}/u.test( words[ i ].w ) || /^[a-z]/.test( words[ i ].w ) ) {
j = i;
while ( j < words.length && ( /^\p{Lu}/u.test( words[ j ].w ) || /^[a-z]/.test( words[ j ].w ) ) ) {
// break the run at sentence boundaries so phrases do
// not bleed across full stops, e.g. "Movement. Gandhi"
if ( j > i && /[.!?;:]/.test( text.slice( words[ j - 1 ].end, words[ j ].start ) ) ) {
break;
}
j++;
}
runLen = j - i;
for ( len = Math.min( 4, runLen ); len >= 1; len-- ) {
for ( s = i; s + len <= j; s++ ) {
win = words.slice( s, s + len );
lows = win.map( function ( x ) { return x.w.toLowerCase(); } );
if ( len === 1 && STOP.indexOf( lows[ 0 ] ) !== -1 ) continue;
if ( lows.every( function ( w ) { return STOP.indexOf( w ) !== -1; } ) ) continue;
parts = win.map( function ( x, k ) { return k === win.length - 1 ? stripPossessive( x.w ) : x.w; } );
lookup = parts.join( ' ' );
if ( lookup.length < 2 ) continue;
if ( /^\d+$/.test( lookup.replace( /\s/g, '' ) ) ) continue;
lookup = lookup.charAt( 0 ).toUpperCase() + lookup.slice( 1 );
pluralS = '';
if ( /s$/i.test( lookup ) && lookup.length > 3 ) {
singular = lookup.replace( /s$/i, '' );
if ( singular.length > 2 ) {
pluralS = lookup.match( /s$/i )[ 0 ];
lookup = singular;
}
}
key = lookup.toLowerCase();
if ( seen[ key ] ) continue;
seen[ key ] = true;
cands.push( {
term: lookup,
pluralS: pluralS,
display: text.slice( win[ 0 ].start, win[ win.length - 1 ].end ),
start: win[ 0 ].start,
end: win[ win.length - 1 ].end,
words: len
} );
}
}
i = j;
} else {
i++;
}
}
}
abs += line.length + 1;
}
}
return cands;
}
function greedyFilter( cands ) {
var sorted = cands.slice().sort( function ( a, b ) { return ( b.words - a.words ) || ( a.start - b.start ); } );
var taken = [], out = [], c, t, overlap, k;
for ( var x = 0; x < sorted.length; x++ ) {
c = sorted[ x ];
overlap = false;
for ( k = 0; k < taken.length; k++ ) {
t = taken[ k ];
if ( c.start < t[ 1 ] && t[ 0 ] < c.end ) { overlap = true; break; }
}
if ( !overlap ) { taken.push( [ c.start, c.end ] ); out.push( c ); }
}
return out;
}
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 );
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 += 50 ) {
batches.push( lookups.slice( i, i + 50 ) );
}
var chain = $.Deferred().resolve().promise();
batches.forEach( function ( batch, bi ) {
chain = chain.then( function () {
if ( onProgress ) onProgress( Math.min( ( bi + 1 ) * 50, 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 ) {}
} );
}() );