Jump to content

User:Nardog/SmartDiff.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.
mw.loader.using([
	'mediawiki.util', 'mediawiki.Title', 'mediawiki.api'
], function smartDiff() {
	mw.loader.addStyleTag('.smartdiff-link.extiw, .smartdiff-link.external{color:var(--color-progressive,#36c)} .smartdiff-link.extiw:visited, .smartdiff-link.external:visited{color:#795cb2} .smartdiff-link.extiw:active, .smartdiff-link.external:active{color:#faa700}');
	class SmartDiff {
		constructor($diff) {
			this.$diff = $diff;
			this.isSpecial = mw.config.get('wgNamespaceNumber') === -1;
			this.isView = mw.config.get('wgAction') === 'view' &&
				new URLSearchParams(location.search).get('diffonly') !== '1';
			this.magicWords = [
				'!', 'BASEPAGENAME', 'BASEPAGENAME:', 'BASEPAGENAMEE', 'BASEPAGENAMEE:',
				'canonicalurl:', 'CURRENTDAY', 'CURRENTDAY2', 'CURRENTDAYNAME',
				'CURRENTDOW', 'CURRENTHOUR', 'CURRENTMONTH', 'CURRENTMONTH1',
				'CURRENTMONTHABBREV', 'CURRENTMONTHNAME', 'CURRENTMONTHNAMEGEN',
				'CURRENTTIME', 'CURRENTTIMESTAMP', 'CURRENTVERSION', 'CURRENTWEEK',
				'CURRENTYEAR', 'DEFAULTCATEGORYSORT:', 'DEFAULTSORT:', 'DEFAULTSORTKEY:',
				'DISPLAYTITLE:', 'filepath:', 'formatnum:', 'FULLPAGENAME',
				'FULLPAGENAME:', 'FULLPAGENAMEE', 'FULLPAGENAMEE:', 'fullurl:',
				'gender:', 'int:', 'lc:', 'lcfirst:', 'LOCALDAY', 'LOCALDAY2',
				'LOCALDAYNAME', 'LOCALDOW', 'LOCALHOUR', 'LOCALMONTH', 'LOCALMONTH1',
				'LOCALMONTHABBREV', 'LOCALMONTHNAME', 'LOCALMONTHNAMEGEN', 'LOCALTIME',
				'LOCALTIMESTAMP', 'LOCALWEEK', 'LOCALYEAR', 'msg:', 'msgnw:',
				'NAMESPACE', 'NAMESPACE:', 'NAMESPACEE', 'NAMESPACEE:', 'NAMESPACENUMBER',
				'NAMESPACENUMBER:', 'ns:', 'NUMBEROFACTIVEUSERS', 'NUMBEROFARTICLES',
				'NUMBEROFEDITS', 'NUMBEROFFILES', 'NUMBEROFPAGES', 'NUMBEROFUSERS',
				'padleft:', 'PAGENAME', 'PAGENAMEE', 'PAGESINCAT:', 'PAGESINCATEGORY:',
				'plural:', 'REVISIONDAY', 'REVISIONDAY:', 'REVISIONDAY2', 'REVISIONDAY2:',
				'REVISIONID', 'REVISIONID:', 'REVISIONMONTH', 'REVISIONMONTH:',
				'REVISIONMONTH1', 'REVISIONMONTH1:', 'REVISIONSIZE', 'REVISIONTIMESTAMP',
				'REVISIONTIMESTAMP:', 'REVISIONUSER', 'REVISIONUSER:', 'REVISIONYEAR',
				'REVISIONYEAR:', 'ROOTPAGENAME', 'ROOTPAGENAME:', 'ROOTPAGENAMEE',
				'ROOTPAGENAMEE:', 'SHORTDESC:', 'SUBJECTPAGENAME', 'SUBJECTPAGENAME:',
				'SUBJECTPAGENAMEE', 'SUBJECTPAGENAMEE:', 'SUBJECTSPACE', 'SUBJECTSPACE:',
				'SUBJECTSPACEE', 'SUBJECTSPACEE:', 'SUBPAGENAME', 'SUBPAGENAME:',
				'SUBPAGENAMEE', 'SUBPAGENAMEE:', 'TALKPAGENAME', 'TALKPAGENAME:',
				'TALKPAGENAMEE', 'TALKPAGENAMEE:', 'TALKSPACE', 'TALKSPACE:',
				'TALKSPACEE', 'TALKSPACEE:', 'uc:', 'ucfirst:', 'urlencode:'
			];
			if (window.smartdiffMagicWords) {
				this.magicWords.push(...window.smartdiffMagicWords);
			}
			try {
				this.subNs = mw.config.get('wgVisualEditorConfig').namespacesWithSubpages;
			} catch (e) {}
			if (!this.subNs) {
				this.subNs = Object.keys(mw.config.get('wgFormattedNamespaces'))
					.map(k => Number(k)).filter(ns => ![0, 6, 8].includes(ns));
			}
			this.re = /((?:\[(?:<[^>]*>)?\[|(?<!{(?:<[^>]*>)?){(?:<[^>]*>)?{(?:<[^>]*>)?(?:(?:#(?:<[^>]*>)?invoke|(?:safe)?subst|msg(?:nw)?|raw)(?:<[^>]*>)?:)?)(?:\s*(?:<[^>]*>)?&lt;(?:<[^>]*>)?tvar(?:<[^>]*>)?\s(?!&gt;).*?&gt;)?\s*)((?:(?!&[gl]t;)[^\[\]{|}])+?)(?=\s*(?:(?:<[^>]*>)?&lt;(?:<[^>]*>)?\/(?:<[^>]*>)?tvar(?:<[^>]*>)?&gt;(?:<[^>]*>)?\s*)?(?:\||\](?:<[^>]*>)?\]|}(?:<[^>]*>)?}|$))/g;
			this.headRe = /^((?:(?:<[^>]*>)*=){1,6}(?:<[^>]*>)?\s*)((?:(?!&[gl]t;).)+?)(?=\s*(?:(?:<[^>]*>)?=){1,6}(?:<[^>]*>|\s)*(?:&lt;|$))/g;
			this.urlRe = /(?:https?(?:<[^>]*>)?:(?:<[^>]*>)?|(?<=\[(?:<[^>]*>)?))\/(?:<[^>]*>)?\/(?:[-\dA-Za-z]+|<[^>]*>)+\.(?:[-.\d:A-Za-z]+|<[^>]*>)+(?:\/(?:(?:[!#-%(-;=?-Z_a-z~]+|&amp;|<[^>]*>)*(?:[#-%(+\-\/-9=?-Z_a-z~]|&amp;)(?:<[^>]*>)?)?)?/g;
			if (window.smartdiffTemplates) {
				this.tempRe = /( data-smartdiff-temp="(\d+)">[^{|}]+)(\|(?:(?!&[gl]t;)[^\[\]{}]|{(?:<[^>]*>)?{(?:<[^>]*>)?!(?:<[^>]*>)?}(?:<[^>]*>)?})+)(?=}(?:<[^>]*>)?}|$)/g;
				this.tempSubRe = /((?:\s|{(?:<[^>]*>)?{(?:<[^>]*>)?!(?:<[^>]*>)?}(?:<[^>]*>)?}[^<>|]*|<[^>]*>)*(?:\|(?:\s|(?:<[^>]*>)|\d+(?:\s|<[^>]*>)*=|[^\d<=>|](?:[^<=>|]|<[^>]*>)*=(?:[^<=>|]|<[^>]*>)*\|?)*|$))/;
				this.templates = window.smartdiffTemplates;
			}
			['rep', 'headRep', 'urlRep', 'tempRep'].forEach(fn => {
				this[fn] = this[fn].bind(this);
			});
			this.side = 'old';
			$diff.find('.diff-deletedline > div').get().forEach(this.processDiv, this);
			this.side = 'new';
			$diff.find('.diff-addedline > div').get().forEach(this.processDiv, this);
			let $contexts = $diff.find('.diff-context > div');
			$contexts.each((i, div) => {
				if (i % 2) {
					this.side = 'new';
					if (this.propUsed && this.getProp() !== this.getProp('pn', 'old')) {
						this.processDiv(div);
					} else {
						$contexts.eq(i).replaceWith($contexts.eq(i - 1).clone());
					}
				} else {
					this.side = 'old';
					this.propUsed = false;
					this.processDiv(div);
				}
			});
			this.links = {};
			$diff.find('.smartdiff-link:not(.external)').each((i, link) => {
				let title = link.title;
				if (!title) return;
				if (!this.links.hasOwnProperty(title)) {
					this.links[title] = [];
				}
				this.links[title].push(link);
			});
			this.query(Object.keys(this.links).slice(0, 500));
			if (this.hasError) {
				mw.notify('SmartDiff error', { type: 'warn' });
			}
		}
		processDiv(div) {
			if (div.querySelector('a[href]')) return;
			let origHtml = div.innerHTML;
			let newHtml = origHtml.replace(this.urlRe, this.urlRep)
				.replace(this.re, this.rep).replace(this.headRe, this.headRep);
			if (this.tempRe) {
				newHtml = newHtml.replace(this.tempRe, this.tempRep);
			}
			if (newHtml === origHtml) return;
			let $newDiv = $('<div>').html(newHtml);
			if (this.detectErrors($newDiv, newHtml, origHtml, div)) return;
			div.textContent = '';
			$newDiv.contents().appendTo(div);
		}
		rep($0, $1, $2) {
			if ($0.includes('<a class="smartdiff-link')) return $0;
			let [s, pre, mid, post] = this.stripTags($2, true, $1);
			let t = mw.Title.newFromText(s), isTemp;
			if (t) {
				if ($1.includes('invoke')) {
					t = mw.Title.makeTitle(828, s);
				} else if (s[0] === '/') {
					if (this.subNs.includes(this.getProp('ns'))) {
						t = mw.Title.newFromText(
							this.getProp() + s.replace(/\/+$/, '')
						);
					} else if ($1[0] === '{') {
						t.namespace = 10;
					}
				} else if ($1[0] === '{') {
					if (s[0] === '#') return $0;
					if (!t.namespace && s[0] !== ':') {
						if (!$1.includes('msg') && !$1.includes('raw')) {
							let match = s.match(/^[^:]+(?::(?=.)|$)/);
							if (match && this.magicWords.includes(match[0])) {
								return $0;
							}
						}
						t.namespace = 10;
						isTemp = true;
					}
				} else if ((this.isSpecial || !this.isView) && s[0] === '#') {
					t.title = this.getProp();
				}
			} else if (s.startsWith('../') && this.subNs.includes(this.getProp('ns'))) {
				let chunks = s.split('/');
				let levelCount = chunks.findIndex(v => v !== '..');
				let sup = this.getProp().split('/').slice(0, -levelCount).join('/');
				if (sup) {
					let sub = chunks.slice(levelCount).join('/').replace(/\/+$/, '');
					t = mw.Title.newFromText(sub ? sup + '/' + sub : sup);
				}
			}
			if (!t) return $0;
			let attrs = {
				class: 'smartdiff-link',
				href: t.getUrl()
			};
			if (this.isSpecial || !this.isView || s[0] !== '#') {
				attrs.title = t.toText();
			}
			if (isTemp && this.tempRe) {
				let name = t.getMainText();
				let idx = this.templates.findIndex(temp => temp.names.includes(name));
				if (idx !== -1) {
					attrs['data-smartdiff-temp'] = idx;
				}
			}
			return pre + $('<a>').attr(attrs).html(mid)[0].outerHTML + post;
		}
		stripTags(s, decode, pre = '', post = '') {
			let mid = s, tags = s.match(/<\/?(?:ins|del)[^>]*>/g);
			s = $($.parseHTML(s.replace(/&amp;/g, '&'))).text();
			if (decode) {
				try {
					s = decodeURIComponent(s);
				} catch (e) {}
			}
			if (tags) {
				if (tags[0][1] === '/') {
					pre += tags[0];
					mid = `<${tags[0].slice(2, 5)} class="diffchange diffchange-inline">` + mid;
				}
				let lastTag = tags.pop();
				if (lastTag[1] !== '/') {
					mid += `</${lastTag.slice(1, 4)}>`;
					post = lastTag + post;
				}
			}
			return [s, pre, mid, post];
		}
		headRep($0, $1, $2) {
			if ($0.includes('<a class="smartdiff-link')) return $0;
			let [s, pre, mid, post] = this.stripTags($2, true, $1);
			s = s.replace(/'''(.+?)'''|<\/?(?:abbr|b|bdi|bdo|big|cite|code|data|del|dfn|em|font|i|ins|kbd|mark|nowiki|q|rb|ref|rp|rt|rtc|ruby|s|samp|small|span|strike|strong|sub|sup|templatestyles|time|translate|tt|u|var)(?:\s[^>]*)?>/gi, '$1')
				.replace(/''(.+?)''/g, '$1');
			let t = mw.Title.newFromText(
				`${this.isSpecial || !this.isView ? this.getProp() : ''}#${s}`
			);
			if (!t) return $0;
			let attrs = {
				class: 'smartdiff-link',
				href: t.getUrl()
			};
			if (this.isSpecial || !this.isView) {
				attrs.title = t.toText();
			}
			return pre + $('<a>').attr(attrs).html(mid)[0].outerHTML + post;
		}
		urlRep($0) {
			let [url, pre, mid, post] = this.stripTags($0);
			return pre + $('<a>').attr({
				class: 'smartdiff-link external',
				href: url,
				rel: 'nofollow'
			}).html(mid)[0].outerHTML + post;
		}
		tempRep($0, $1, $2, $3) {
			if ($3.includes('<a class="smartdiff-link')) return $0;
			let temp = this.templates[$2];
			return $1 + $3.split(this.tempSubRe).map((os, i) => {
				if (!os || i % 2) return os;
				let j = i / 2;
				if (j < temp.start || j > temp.end ||
					temp.skipOdd && j % 2 || temp.skipEven && j % 2 === 0
				) {
					return os;
				}
				let [s, pre, mid, post] = this.stripTags(os, true);
				if (temp.prefix) {
					s = temp.prefix + s;
				}
				if (temp.suffix) {
					s += temp.suffix;
				}
				let t = temp.forceNs
					? mw.Title.makeTitle(temp.namespace, s)
					: mw.Title.newFromText(s, temp.namespace);
				if (!t) return os;
				let params = (j >= temp.noRedirectStart || j <= temp.noRedirectEnd) &&
					{ redirect: 'no' };
				return pre + $('<a>').attr({
					class: 'smartdiff-link',
					href: t.getUrl(params),
					title: t.toText()
				}).html(mid)[0].outerHTML + post;
			}).join('');
		}
		getProp(n = 'pn', side = this.side) {
			this.propUsed = true;
			if (this[side]) {
				if (this[side][n]) {
					return this[side][n];
				}
			} else {
				this[side] = {};
				let link = this.$diff[0].querySelector(
					side === 'old'
						? '#mw-diff-otitle1 a, #differences-prevlink'
						: '#mw-diff-ntitle1 a, #differences-nextlink'
				);
				if (link) {
					let pn = mw.util.getParamValue('title', link.search);
					this[side].pn = pn;
					this[side].ns = mw.Title.newFromText(pn).namespace;
					return this[side][n];
				}
			}
			if (this[n]) {
				return this[n];
			}
			if (this.isSpecial) {
				this.pn = '';
				this.ns = 0;
			} else {
				this.pn = mw.config.get('wgPageName');
				this.ns = mw.config.get('wgNamespaceNumber');
			}
			return this[n];
		}
		query(titles) {
			if (!titles.length) return;
			new mw.Api().post({
				action: 'query',
				titles: titles.slice(0, 50),
				iwurl: 1,
				prop: 'info',
				inprop: 'linkclasses',
				inlinkcontext: this.getProp(),
				formatversion: 2
			}, {
				headers: { 'Promise-Non-Write-API-Action': 1 }
			}).then(response => {
				let query = response && response.query;
				if (!query) return;
				let data = {};
				(query.pages || []).forEach(page => {
					let obj = { classes: page.linkclasses || [] };
					if (page.missing && !page.known) {
						obj.classes.push('new');
						obj.params = { action: 'edit', redlink: 1 };
					}
					data[page.title] = obj;
				});
				(query.interwiki || []).forEach(interwiki => {
					data[interwiki.title] = {
						classes: ['extiw'],
						url: interwiki.url
					};
				});
				(query.normalized || []).forEach(entry => {
					if (!data.hasOwnProperty(entry.to)) return;
					let obj = data[entry.to];
					obj.canonical = entry.to;
					if (!obj.url) {
						obj.url = mw.util.getUrl(entry.to, obj.params);
					}
					data[entry.from] = obj;
				});
				Object.entries(data).forEach(([title, obj]) => {
					if (!this.links.hasOwnProperty(title)) return;
					let $links = $(this.links[title]).addClass(obj.classes)
						.attr('title', obj.canonical);
					if (obj.url) {
						$links.attr('href', function () {
							return obj.url + this.hash;
						});
					}
				});
				this.query(titles.slice(50));
			});
		}
		detectErrors($newDiv, newHtml, origHtml, div) {
			let comp = $newDiv.html();
			if (comp !== newHtml) {
				console.warn(
					'SmartDiff syntax error at:\n',
					div,
					`\nNew HTML:\n${newHtml}\nCompared against:\n${comp}`
				);
				this.hasError = true;
				return true;
			}
			let $comp = $newDiv.clone();
			$comp.find('.smartdiff-link').contents().unwrap();
			comp = $comp.html().replace(/<\/(ins|del)><\1[^>]*>/g, '');
			if (comp !== origHtml) {
				console.warn(
					'SmartDiff mutation error at:\n',
					div,
					`\nOriginal HTML:\n${origHtml}\nCompared against:\n${comp}`
				);
				this.hasError = true;
				return true;
			}
		}
	}
	mw.hook('wikipage.diff').add($diff => {
		new SmartDiff($diff);
	});
});