MediaWiki:Gadget-OrangeLinks.js

Note – after saving, you may have to bypass your browser’s cache to see the changes.

  • Mozilla / Firefox / Safari: hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Command-R on a Macintosh);
  • Konqueror and Chrome: click Reload or press F5;
  • Opera: clear the cache in Tools → Preferences;
  • Internet Explorer: hold Ctrl while clicking Refresh, or press Ctrl-F5.

Colors links with a language name as anchor if there is no entry for that language on the page linked to.

See also: Special:Gadgets.


/**
 * General idea: for each bluelink with an anchor, the script fetches the categories for its target page
 * and checks whether it contains a part-of-speech category. If a suitable category is found, the script
 * assumes the anchor is valid. If not, the link is coloured orange.
 * 
 * Previous version by [[User:Yair rand]], based in turn on an idea by [[User:Hippietrail]].
 * This script is a complete rewrite.
 */

// <nowiki>
/* jshint esversion: 5, loopfunc: true, latedef: true, scripturl: true, undef: true, unused: true */
/* globals mw, jQuery */

(function () {
'use strict';

// Disable orange links on a page with a HTML tag containing id="no-orange-links".
if (document.querySelector("#no-orange-links")) return;

var api = new mw.Api();
var fresh = [], // list of titles to get categories of
	queue = {}, // map from title to list of links with that title
	catcache = {}; // map from page name to list of categories

var articlePathRx = new RegExp('^'
	+ mw.util.escapeRegExp(mw.config.get('wgArticlePath')).replace('\\$1', '(.+)')
	+ '$'); // hax

var isDebug = mw.util.getParamValue('debug') && window.console;
var debugLogger = {};
// add methods that call console method if "isDebug" is defined
[ "error", "info" ].forEach(function (key) {
	debugLogger[key] = isDebug
		? window.console[key].bind(console)
		: function () {};
});

// see [[mw:Manual:index.php]]
// alternative: /^\/(?:w(?:\/(?:index\.php)?)?)?$/
var indexPhp = [ '/', '/w', '/w/', '/w/index.php' ];

function getPageName(url) {
	if (!(url instanceof mw.Uri))
		throw new TypeError("Expected mw.Uri");
	
	var match = articlePathRx.exec(url.path);
	return match ? match[1]
		// if path is index.php, page name is value of "title" parameter
		: indexPhp.indexOf(url.path) !== -1 ? url.query.title
		: undefined;
}

// These lists are not as normative. Common headers are included even when they
// are not officially recommended.
var POSHeaders = [
	'Abbreviation', 'Acronym', 'Adjective', 'Adjectival noun', 'Adnominal',
	'Adverb', 'Ambiposition', 'Article', 'Brivla', 'Circumfix',
	'Circumposition', 'Classifier', 'Combining form', 'Conjunction',
	'Contraction', 'Counter', 'Determiner', 'Diacritical mark', 'Gerund',
	'Gismu', 'Han character', 'Hanja', 'Hanzi', 'Ideophone', 'Idiom', 'Infix',
	'Initialism', 'Interfix', 'Interjection', 'Kanji', 'Letter', 'Ligature',
	'Logogram', 'Noun', 'Number', 'Numeral', 'Ordinal number', 'Participle',
	'Particle', 'Phrase', 'Postposition', 'Predicative', 'Prefix',
	'Preposition', 'Prepositional phrase', 'Pronoun', 'Proper noun', 'Proverb',
	'Punctuation mark', 'Rafsi', 'Relative', 'Romanization', 'Root', 'Stem',
	'Suffix', 'Syllable', 'Symbol', 'Transliteration', 'Verb', 'Verbal noun',
];

var otherHeaders = [
	'Abbreviations', 'Alternative forms', 'Alternative scripts', 'Anagrams',
	'Antonyms', 'Citations', 'Compounds', 'Conjugation', 'Coordinate terms',
	'Declension', 'Definitions', 'Derived characters', 'Derived terms',
	'Descendants', 'Etymology', 'External links', 'Forms', 'Further reading',
	'Gallery', 'Glyph origin', 'Holonyms', 'Homophones', 'Hypernyms',
	'Hyponyms', 'Idioms', 'Inflection', 'Meronyms', 'Nouns and adjectives',
	'Paronyms', 'Production', 'Pronunciation', 'Proverbs', 'Quotations',
	'Readings', 'References', 'Related characters', 'Related terms', 'See also',
	'Statistics', 'Stem set', 'Synonyms', 'Translations', 'Troponyms',
	'Usage notes', 'Verbal stems', 'Verbs', 'Verbs and verb derivatives',
	'Verbs and verbal derivatives',
];
var knownNonLanguageHeader = new RegExp('^(' + otherHeaders.join('|')
	+ '|' + POSHeaders.join('|') + ')', 'i');

// header is not language header if it does not begin with a character that
// occurs at the beginning of a language name (refer to
// [[Special:PermaLink/51354043]]) and is not a recognized non-language header
function maybeLanguageHeader(header) {
	return header && /^[A-Z'ÀÁÖǀǁǂǃ]/.test(header)
	&& !knownNonLanguageHeader.test(header);
}

// get page title if link points to another page on this wiki
function getTitle(link) {
	if (!(link instanceof HTMLAnchorElement))
		throw new TypeError("Expected HTMLAnchorElement");
	
	// XXX: is there no other way to skip TabbedLanguages tabs?
	var parentClasses = link.parentNode.classList;
	if (parentClasses.contains("unselectedTab")
	|| parentClasses.contains("selectedTab"))
		return;
	
	// skip empty hrefs, script URLs, local links
	var rawHref = link.getAttribute('href');
	if (!(rawHref && rawHref.indexOf("javascript:") !== 0 && rawHref[0] !== "#"))
		return;
	
	var url = new mw.Uri(link.href);
	// check that the domain is correct
	if (url.getAuthority() !== location.hostname)
		return;
	
	var pageName = getPageName(url);
	
	return pageName
		? new mw.Title(decodeURIComponent(pageName
			+ '#' + url.fragment.replace(/\.([0-9A-Fa-f][0-9A-Fa-f])/g, '%$1')))
		: undefined;
}

// XXX: save resources by caching something or other between calls to
// processLink on same link?
function processLink(link) {
	try {
		if (link._ORANGED === link.href) // XXX: eliminate this hack
			return;

		var title = getTitle(link);
		
		// check that title is in main namespace and that the fragment could be
		// a language name
		if (!(title && title.getNamespaceId() === 0 && maybeLanguageHeader(title.fragment)))
			return;
		
		var prefixedText = title.getPrefixedText();
		var categories = catcache[prefixedText];
		if (categories) {
			link._ORANGED = link.href;

			// remove sense ID from anchor
			var langName = title.getFragment().replace(/: .+$/, '');
			
			// We discount "German Low German", etc. by requiring the word
			// immediately after the language name to be "lemmas",
			// "non-lemma forms", etc. This (intentionally) excludes
			// "LANG entries that don't exist" so that they appear orange
			if (!categories.some(function (category) {
				return category.indexOf(langName) === 0
				&& /^ (lemmas|non-lemma|logograms|Han tu|Nom|Han characters)/.test(category.substring(langName.length));
			})) {
				link.classList.add('partlynew');
				debugLogger.info('partlynew ', link, '; cats=', categories,
					' frag=', title.fragment, ' langName=', langName);
			}
		} else {
			var entry = queue[prefixedText];
			if (!entry) {
				entry = queue[prefixedText] = [];
				fresh.push(prefixedText);
			}
			entry.push(link);
		}
	} catch (e) {
		debugLogger.error(e, 'while processing', link, ' href=', link.href);
	}
}

function processQueue(queue) {
	function collect(titles) {
		return jQuery.Deferred(function (d) {
			var query = {
				'action': 'query',
				'titles': titles.join('|'),
				'redirects': 1,
				'prop': 'categories',
				'cllimit': 100,
			};
			
			function fail(code, details, xhr) {
				d.reject(code, details, xhr);
			}
			
			function pluckResults(result) {
				debugLogger.info('result', result);
				var pages = result.query.pages, redirects = result.query.redirects;
				for (var pageid in pages) {
					var title = new mw.Title(pages[pageid].title).getPrefixedText();
					var categories = pages[pageid].categories || [];
					var cache = catcache[title];
					if (!cache)
						cache = catcache[title] = [];
					
					// 'Category:'.length === 9
					for (var i = 0; i < categories.length; ++i)
						cache.push(categories[i].title.substring(9));

					debugLogger.info('cache for', title, 'is', cache);
				}
				
				if (redirects) {
					redirects.forEach(function (redirect) {
						var origin = new mw.Title(redirect.from).getPrefixedText();
						var target = new mw.Title(redirect.to).getPrefixedText();
						catcache[origin] = catcache[target];
					});
				}
				
				if (result['continue']) {
					api.get(jQuery.extend(query, result['continue'])).then(pluckResults, fail);
				} else
					d.resolve(titles);
			}

			api.get(query).then(pluckResults, fail);
		});
	}
	
	while (fresh.length > 0) {
		collect(fresh.splice(0, 25)).then(function (titles) {
			titles.forEach(function (title) {
				queue[title].forEach(processLink);
			});
		}, function (code, details, xhr) {
			console.error(code, details, xhr);
		});
	}
	fresh = [];
}

Array.prototype.forEach.call(document.getElementsByTagName('a'), processLink);
processQueue(queue);

if (mw.util.getParamValue('@orangelinks.no_live')
|| typeof window.MutationObserver !== "function")
	return;

var mo = new MutationObserver(function (events) {
	for (var i = 0; i < events.length; ++i) {
		var event = events[i];
		if (event.type === 'childList') {
			event.addedNodes.forEach(function (addedNode) {
				if (addedNode instanceof Element) {
					if (addedNode.tagName === 'A')
						processLink(addedNode);
					else
						Array.prototype.forEach.call(
							addedNode.getElementsByTagName('a'), processLink);
				}
			});
		} else if (event.type === 'attributes' && event.target.tagName === 'A'
		&& (event.attributeName === 'href' || event.attributeName === 'class'))
			processLink(event.target);
	}
	processQueue(queue);
});

mo.observe(document, {
	attributes: true,
	childList: true,
	subtree: true
});

})();

// </nowiki>