User:Nw520/HashCheck.js
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)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Press Ctrl-F5.
// <nowiki>
mw.hook( 'wikipage.content' ).add( async function hook( /** @type {Array<HTMLElement>} */ $container ) {
mw.hook( 'wikipage.content' ).remove( hook );
await mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] );
const FILTER_COLOUR = {
GREEN: 'invert(50%) sepia(100%) saturate(2687%) hue-rotate(120deg)',
PURPLE: 'invert(20%) sepia(68%) saturate(2914%) hue-rotate(276deg)',
RED: 'invert(25%) sepia(100%) saturate(6225%) hue-rotate(345deg)',
YELLOW: 'invert(75%) sepia(50%) saturate(635%)'
};
const ICON = {
CHECK: 'https://upload.wikimedia.org/wikipedia/commons/b/bd/Font_Awesome_5_solid_check.svg',
CLOCK: 'https://upload.wikimedia.org/wikipedia/commons/2/23/Font_Awesome_5_solid_clock.svg',
HOURGLASS: 'https://upload.wikimedia.org/wikipedia/commons/6/61/Font_Awesome_5_solid_hourglass-half.svg',
QUESTION_MARK: 'https://upload.wikimedia.org/wikipedia/commons/9/90/Font_Awesome_5_solid_question.svg',
TRIANGLE: 'https://upload.wikimedia.org/wikipedia/commons/6/6a/Font_Awesome_5_solid_exclamation-triangle.svg'
};
const strings = {
'ext-hashcheck-dnf': {
de: 'Fragment nicht gefunden',
en: 'Fragment not found'
},
'ext-hashcheck-jump': {
de: 'Zu erstem Vorkommen springen',
en: 'Jump to first occurrence'
},
'ext-hashcheck-maxage': {
de: 'Alter der Daten: $1 min',
en: 'Age of data: $1 min'
},
'ext-hashcheck-ok': {
de: 'Fragment gefunden',
en: 'Fragment found'
},
'ext-hashcheck-pending': {
de: 'Überprüfung läuft',
en: 'Check in progress'
},
'ext-hashcheck-portlet': {
de: 'Fragmente in Links prüfen',
en: 'Check fragments in links'
},
'ext-hashcheck-queued': {
de: 'Überprüfung eingereiht',
en: 'Check queued'
},
'ext-hashcheck-warnbox-preamble': {
de: 'In diesem Reiseführer sind Links, bei denen die verlinkten Sektionen nicht existieren:',
en: 'In this guide are links where the linked sections do not exist:'
}
};
/**
* @type {HTMLSpanElement}
*/
let maxAgeBox = null;
/**
* @type {HTMLElement}
*/
let portlet = null;
/**
* @type {HTMLDivElement}
*/
let warnbox = null;
function main() {
if ( ( mw.config.get( 'wgAction' ) !== 'submit' && mw.config.get( 'wgAction' ) !== 'view' ) || mw.config.get( 'wgNamespaceNumber' ) !== 0 ) {
return;
}
mw.util.addCSS( '.ext-hashcheck-warnbox { background: #f9f9f9; border: 1px solid #f66; border-left: 10px solid #f66; box-sizing: border-box; margin: .5em 0; overflow: hidden; padding: .5em; text-align: left; width: auto; }' );
setupStrings();
addPortlet();
fireHook();
}
function addPortlet() {
portlet = mw.util.addPortletLink( 'p-tb', '#', mw.msg( 'ext-hashcheck-portlet' ) );
portlet.addEventListener( 'click', ( e ) => {
e.preventDefault();
portlet.remove();
scan();
} );
}
/**
* @param {string} title
* @param {string} hash
* @param {Array<HTMLAnchorElement>} elements
* @param {?number} age
*/
function addWarning( title, hash, elements, age ) {
if ( warnbox === null ) {
warnbox = document.createElement( 'div' );
warnbox.classList.add( 'ext-hashcheck-warnbox' );
warnbox.innerHTML = `<span class="ext-hashcheck-maxage" style="float: right; font-size: .8em;"></span>
<p>${mw.msg( 'ext-hashcheck-warnbox-preamble' )}</p>
<ul class="ext-hashcheck-titles"></ul>`;
maxAgeBox = warnbox.querySelector( '.ext-hashcheck-maxage' );
document.getElementById( 'bodyContent' ).insertAdjacentElement( 'afterbegin', warnbox );
}
let titleList = null;
if ( warnbox.querySelector( `li[data-title="${encodeTitle( title )}"]` ) === null ) {
const li = document.createElement( 'li' );
li.dataset.title = title.replace( / /g, '_' );
li.textContent = title;
titleList = document.createElement( 'ul' );
li.appendChild( titleList );
warnbox.querySelector( '.ext-hashcheck-titles' ).append( li );
} else {
titleList = warnbox.querySelector( `li[data-title="${encodeTitle( title )}"] > ul` );
}
if ( titleList.querySelector( `li[data-hash="${encodeTitle( hash )}"]` ) !== null ) {
return;
}
const hashItem = document.createElement( 'li' );
hashItem.dataset.age = age;
const link = document.createElement( 'a' );
link.href = elements[ 0 ].href;
link.textContent = hash;
link.target = '_blank';
hashItem.appendChild( link );
hashItem.appendChild( document.createTextNode( ' (' ) );
const elementLink = document.createElement( 'a' );
elementLink.href = '#';
elementLink.textContent = `${elements.length}×`;
elementLink.title = mw.msg( 'ext-hashcheck-jump' );
elementLink.addEventListener( 'click', ( e ) => {
e.preventDefault();
elements[ 0 ].scrollIntoView( {
behavior: 'smooth',
block: 'center',
inline: 'center'
} );
} );
hashItem.appendChild( elementLink );
hashItem.appendChild( document.createTextNode( ')' ) );
titleList.appendChild( hashItem );
}
/**
* @param {string} title
* @return {string}
*/
function encodeTitle( title ) {
return title.replace( /"/g, '\\"' ).replace( / /g, '_' );
}
function fireHook() {
mw.hook( 'ext.hashCheck.loaded' ).fire( window.nw520.hashCheck );
}
/**
* @param {string} iconUrl
* @param {string} filter
* @return {string}
*/
function getImage( iconUrl, filter ) {
return `<img src="${iconUrl}" style="filter: ${filter};height:1em;" />`;
}
/**
* @return {Object.<string, Object.<string, Array<HTMLAnchorElement>>>} Dictionary of titles and (dictionaries of hashes and list of elements).
*/
function getLinks() {
/** @type {Object.<string, Object.<string, Array<HTMLAnchorElement>>>} */
const links = {};
/** @type {Array<HTMLAnchorElement>} */
const elements = Array.from( $container[ 0 ].querySelectorAll( 'a[href^="/wiki/"][href*="#"]:not(.new), a[href^="#"]' ) );
for ( const element of elements ) {
if ( element.closest( '#toc' ) !== null ) {
// Ignore table of contents
continue;
}
const parts = decodeURI( element.attributes.href.value ).replace( /^\/wiki\//, '' )
.replace( /_/g, ' ' )
.match( /^(?<title>[^#]+?)?(?:#(?<hash>.*?))?$/ );
if ( parts.groups.title === undefined ) {
parts.groups.title = '';
}
if ( links[ parts.groups.title ] === undefined ) {
links[ parts.groups.title ] = {};
}
if ( parts.groups.hash === '' || parts.groups.hash.startsWith( 'cite note-' ) || parts.groups.hash.startsWith( 'cite ref-' ) || parts.groups.hash.startsWith( '/map/' ) || parts.groups.hash.startsWith( '/maplink/' ) || parts.groups.hash.startsWith( 'movedpara' ) ) {
continue;
}
if ( links[ parts.groups.title ][ parts.groups.hash ] === undefined ) {
links[ parts.groups.title ][ parts.groups.hash ] = [ element ];
} else {
links[ parts.groups.title ][ parts.groups.hash ].push( element );
}
}
return links;
}
async function scan() {
portlet?.remove();
const links = getLinks();
let maxAge = 0;
// Set to queued
for ( const hashes of Object.values( links ) ) {
for ( const elements of Object.values( hashes ) ) {
for ( const element of elements ) {
const controlItem = document.createElement( 'span' );
controlItem.classList.add( 'ext-hashcheck-control' );
controlItem.innerHTML = getImage( ICON.HOURGLASS, FILTER_COLOUR.PURPLE );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-queued' )}`;
element.appendChild( controlItem );
element.classList.add( 'ext-hashcheck-queued' );
}
}
}
for ( let [ title, hashes ] of Object.entries( links ) ) {
if ( title === '' && mw.config.get( 'wgAction' ) === 'view' ) {
// Current page and in view-mode, no request needed
for ( const [ hash, elements ] of Object.entries( hashes ) ) {
const match = document.querySelector( `*[id="${encodeTitle( hash )}"]` );
for ( const element of elements ) {
/** @type {HTMLSpanElement} */
const controlItem = element.querySelector( '.ext-hashcheck-control' );
if ( match !== null ) {
controlItem.innerHTML = getImage( ICON.CHECK, FILTER_COLOUR.GREEN );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-ok' )}`;
element.classList.add( 'ext-hashcheck-ok' );
} else {
controlItem.innerHTML = getImage( ICON.TRIANGLE, FILTER_COLOUR.RED );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-dnf' )}`;
element.classList.add( 'ext-hashcheck-dnf' );
}
}
if ( match === null ) {
addWarning( title === '' ? mw.config.get( 'wgTitle' ) : title, hash, elements, 0 );
}
}
} else {
if ( title === '' ) {
title = mw.config.get( 'wgPageName' );
}
// Set to pending
for ( const elements of Object.values( hashes ) ) {
for ( const element of elements ) {
/** @type {HTMLSpanElement} */
const controlItem = element.querySelector( '.ext-hashcheck-control' );
controlItem.innerHTML = getImage( ICON.CLOCK, FILTER_COLOUR.PURPLE );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-pending' )}`;
element.classList.add( 'ext-hashcheck-queued' );
element.classList.add( 'ext-hashcheck-pending' );
}
}
// Make request
const request = jQuery.ajax( {
data: {
redirect: true
},
dataType: 'html',
headers: {
'Api-User-Agent': 'HashCheck; [[m:User:nw520/HashCheck.js]]'
},
url: `${mw.config.get( 'wgServer' )}/api/rest_v1/page/html/${encodeURIComponent( title.replace( / /g, '_' ) )}?redirect=true`
} );
const raw = await request;
const age = request.getResponseHeader( 'age' ) !== null ? parseInt( request.getResponseHeader( 'age' ) ) : null;
const parser = new DOMParser();
const article = parser.parseFromString( raw, 'text/html' );
maxAge = Math.max( maxAge, age ?? 0 );
for ( const [ hash, elements ] of Object.entries( hashes ) ) {
const match = article.querySelector( `*[id="${encodeTitle( hash )}"]` );
for ( const element of elements ) {
element.classList.remove( 'ext-hashcheck-pending' );
/** @type {HTMLSpanElement} */
const controlItem = element.querySelector( '.ext-hashcheck-control' );
if ( match !== null ) {
controlItem.innerHTML = getImage( ICON.CHECK, FILTER_COLOUR.GREEN );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-ok' )}`;
element.classList.add( 'ext-hashcheck-ok' );
} else {
controlItem.innerHTML = getImage( ICON.TRIANGLE, FILTER_COLOUR.RED );
controlItem.title = `[HashCheck] ${mw.msg( 'ext-hashcheck-dnf' )}`;
element.classList.add( 'ext-hashcheck-dnf' );
}
}
if ( match === null ) {
addWarning( title, hash, elements, age );
if ( maxAge % 60 >= 3 ) {
maxAgeBox.textContent = mw.msg( 'ext-hashcheck-maxage', maxAge % 60 );
}
}
}
}
}
}
function setupStrings() {
const lang = mw.config.get( 'wgUserLanguage' );
mw.messages.set( Object.fromEntries( Object.keys( strings ).map( ( stringKey ) => {
return [ stringKey, strings[ stringKey ][ lang ] ?? strings[ stringKey ].en ];
} ) ) );
}
if ( window.nw520 === undefined ) {
window.nw520 = [];
}
window.nw520.hashCheck = {
scan: scan
};
main();
} );
// </nowiki>