MediaWiki:Common.js: Difference between revisions
From GxPlex
Gxplex admin (talk | contribs) fix: force equal sidebar table width via style.setProperty important |
Gxplex admin (talk | contribs) No edit summary |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 132: | Line 132: | ||
+ '|?DocSubmittedBy=submittedBy' | + '|?DocSubmittedBy=submittedBy' | ||
+ '|?DocContributors=contributors' | + '|?DocContributors=contributors' | ||
+ '|limit= | + '|limit=5000' | ||
+ '|sort=DocName' | + '|sort=DocName' | ||
+ '|order=asc'; | + '|order=asc'; | ||
/* References' own properties only — no HasDocument.* joins. Chaining through | |||
* HasDocument costs SMW ~21ms per row (subject-side Page-type resolution isn't | |||
* batched), which is 25s+ at 1181 rows and can hang a single-threaded PHP server. | |||
* The Document-derived fields (validityArea, status, ...) are instead joined | |||
* client-side using DOC_REFS_QUERY below, which asks the same link from the | |||
* Document side (323 subjects) — same data, ~20x cheaper. */ | |||
var REF_QUERY = '[[Reference:+]]' | var REF_QUERY = '[[Reference:+]]' | ||
+ '|? | + '|?RefTags=refTags' | ||
+ '| | + '|limit=5000'; | ||
/* Cheap inverse of HasDocument: for each Document, which References point to it. | |||
* Used to join Reference rows to their Document's properties client-side. */ | |||
var DOC_REFS_QUERY = '[[Document:+]]' | |||
+ '|?-HasDocument=refs' | |||
+ '|?HasDocument | + '|limit=5000'; | ||
+ '| | |||
var GLOSSARY_QUERY = '[[Glossary:+]]' | |||
+ '|?GlossaryShort=short' | |||
+ '|?GlossaryTagID=tagId' | |||
+ '|limit=5000'; | |||
+ '|? | |||
+ '|? | |||
+ '|limit= | |||
var allResults = []; | var allResults = []; | ||
var filteredResults = []; | var filteredResults = []; | ||
var currentPage = 1; | var currentPage = 1; | ||
var activeFilters = {}; | var activeFilters = { keywords: {} }; | ||
var extractsCache = {}; // fulltext → 250-char preview string | var extractsCache = {}; // fulltext → 250-char preview string | ||
var renderToken = 0; // incremented on every renderPage; cancels stale extract callbacks | var renderToken = 0; // incremented on every renderPage; cancels stale extract callbacks | ||
var contentMatches = {}; // fulltext → true, for pages whose body matched the last full-text search | |||
/* ---- helpers ---- */ | /* ---- helpers ---- */ | ||
| Line 182: | Line 185: | ||
function splitValues( str ) { | function splitValues( str ) { | ||
return String( str || '' ).split( ' | return String( str || '' ).split( /\s*[,;]\s*/ ).map( function ( v ) { return v.trim(); } ).filter( Boolean ); | ||
} | |||
function splitTagIds( str ) { | |||
return String( str || '' ).split( '++' ).map( function ( v ) { return v.trim(); } ).filter( Boolean ); | |||
} | } | ||
/* ---- parse SMW responses ---- */ | /* ---- parse SMW responses ---- */ | ||
function | function parseDocuments( data ) { | ||
var raw = ( data.query && data.query.results ) || {}; | var raw = ( data.query && data.query.results ) || {}; | ||
return Object.keys( raw ).map( function ( key ) { | return Object.keys( raw ).map( function ( key ) { | ||
| Line 194: | Line 201: | ||
var docName = getProp( p, 'docName' ); | var docName = getProp( p, 'docName' ); | ||
return { | return { | ||
_type: | _type: 'Document', | ||
fulltext: page.fulltext, | fulltext: page.fulltext, | ||
url: page.fullurl, | url: page.fullurl, | ||
displayName: | displayName: docName || page.fulltext.replace( /^Document:/, '' ), | ||
reference: getProp( p, 'reference' ), | reference: getProp( p, 'reference' ), | ||
validityArea: getProp( p, 'validityArea' ), | validityArea: getProp( p, 'validityArea' ), | ||
| Line 212: | Line 217: | ||
submittedBy: getProp( p, 'submittedBy' ), | submittedBy: getProp( p, 'submittedBy' ), | ||
contributors: getProp( p, 'contributors' ), | contributors: getProp( p, 'contributors' ), | ||
tagIds: [], | |||
preview: '' | |||
}; | |||
} ); | |||
} | |||
function parseReferenceOwnProps( data ) { | |||
var raw = ( data.query && data.query.results ) || {}; | |||
return Object.keys( raw ).map( function ( key ) { | |||
var page = raw[ key ]; | |||
var p = page.printouts || {}; | |||
return { | |||
fulltext: page.fulltext, | |||
url: page.fullurl, | |||
tagIds: splitTagIds( getProp( p, 'refTags' ) ) | |||
}; | |||
} ); | |||
} | |||
/* Inverts DOC_REFS_QUERY's Document→[References] into Reference fulltext→Document fulltext. */ | |||
function parseDocRefsMap( data ) { | |||
var raw = ( data.query && data.query.results ) || {}; | |||
var map = {}; | |||
Object.keys( raw ).forEach( function ( docTitle ) { | |||
var refs = ( raw[ docTitle ].printouts && raw[ docTitle ].printouts.refs ) || []; | |||
refs.forEach( function ( ref ) { | |||
if ( ref && ref.fulltext ) { map[ ref.fulltext ] = docTitle; } | |||
} ); | |||
} ); | |||
return map; | |||
} | |||
function buildReferenceResults( refOwnRows, docsByTitle, docRefsMap ) { | |||
return refOwnRows.map( function ( r ) { | |||
var doc = docsByTitle[ docRefsMap[ r.fulltext ] ] || null; | |||
return { | |||
_type: 'Reference', | |||
fulltext: r.fulltext, | |||
url: r.url, | |||
displayName: r.fulltext.replace( /^Reference:/, '' ), | |||
reference: doc ? doc.reference : '', | |||
validityArea: doc ? doc.validityArea : '', | |||
scopes: doc ? doc.scopes : '', | |||
docName: doc ? doc.docName : '', | |||
version: doc ? doc.version : '', | |||
status: doc ? doc.status : '', | |||
docType: doc ? doc.docType : '', | |||
language: doc ? doc.language : '', | |||
description: doc ? doc.description : '', | |||
restricted: doc ? doc.restricted : '', | |||
submittedBy: doc ? doc.submittedBy : '', | |||
contributors: doc ? doc.contributors : '', | |||
tagIds: r.tagIds, | |||
preview: '' | preview: '' | ||
}; | }; | ||
} ); | } ); | ||
} | |||
function parseGlossary( data ) { | |||
var raw = ( data.query && data.query.results ) || {}; | |||
return Object.keys( raw ).map( function ( key ) { | |||
var p = raw[ key ].printouts || {}; | |||
return { | |||
short: getProp( p, 'short' ), | |||
tagId: getProp( p, 'tagId' ) | |||
}; | |||
} ).filter( function ( g ) { return g.short && g.tagId; } ); | |||
} | } | ||
/* ---- text extracts – lazy per page, cached ---- */ | /* ---- text extracts – lazy per page, cached ---- */ | ||
// Documents with no real body text still extract this boilerplate ("== References ==" / | |||
// "''No references yet.''" fallback) — not worth showing as a "preview". | |||
var BLANK_EXTRACT_RE = /^references\s+no references yet\.?$/i; | |||
function fetchPageExtracts( results, token, callback ) { | function fetchPageExtracts( results, token, callback ) { | ||
var missing = results.filter( function ( r ) { return !( r.fulltext in extractsCache ); } ); | var missing = results.filter( function ( r ) { return !( r.fulltext in extractsCache ); } ); | ||
if ( !missing.length ) { callback( token ); return; } | if ( !missing.length ) { callback( token ); return; } | ||
var api = new mw.Api(); | |||
api.get( { | /* One request per title, not a batched multi-title call: TextExtracts silently | ||
* forces exlimit down to 1 whenever exintro isn't set and more than one title is | |||
* requested (ApiQueryExtracts.php), so a batched call here would only ever fill | |||
* in the extract for the first title and silently drop the rest. */ | |||
var api = new mw.Api(); | |||
var remaining = missing.length; | |||
Object.keys( | function onOneDone() { | ||
remaining--; | |||
if ( remaining <= 0 ) { callback( token ); } | |||
} | |||
} | missing.forEach( function ( r ) { | ||
api.get( { | |||
action: 'query', prop: 'extracts', | |||
// No exintro: these pages front-load a metadata table before any heading, so | |||
// intro-only extraction (text before the first heading) is always empty. The | |||
// actual body text lives after the first section heading. | |||
exchars: 250, explaintext: true, exsectionformat: 'plain', | |||
titles: r.fulltext, | |||
format: 'json' | |||
} ).always( function ( data ) { | |||
var pages = ( data && data.query && data.query.pages ) || {}; | |||
var pg = pages[ Object.keys( pages )[ 0 ] ]; | |||
var text = ( pg && pg.extract || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 250 ); | |||
extractsCache[ r.fulltext ] = BLANK_EXTRACT_RE.test( text ) ? '' : text; | |||
onOneDone(); | |||
} ); | |||
} ); | } ); | ||
} | } | ||
| Line 256: | Line 343: | ||
/* ---- render filter buttons ---- */ | /* ---- render filter buttons ---- */ | ||
function renderFilterButtons( filterSection, values ) { | function renderFilterButtons( filterSection, values, glossaryMatches, clearBtn ) { | ||
filterSection.innerHTML = ''; | filterSection.innerHTML = ''; | ||
activeFilters = {}; | activeFilters = { keywords: {} }; | ||
var hasAnyValue = FILTER_GROUPS.some( function ( g ) { | var hasAnyValue = FILTER_GROUPS.some( function ( g ) { | ||
return Object.keys( values[ g.key ] || {} ).length > 0; | return Object.keys( values[ g.key ] || {} ).length > 0; | ||
} ); | } ) || glossaryMatches.length > 0; | ||
if ( !hasAnyValue ) { | if ( !hasAnyValue ) { | ||
| Line 269: | Line 356: | ||
msg.textContent = 'No documents or references indexed yet. Import data to enable filters.'; | msg.textContent = 'No documents or references indexed yet. Import data to enable filters.'; | ||
filterSection.appendChild( msg ); | filterSection.appendChild( msg ); | ||
if ( clearBtn ) { clearBtn.style.display = 'none'; } | |||
return; | return; | ||
} | } | ||
if ( clearBtn ) { clearBtn.style.display = ''; } | |||
FILTER_GROUPS.forEach( function ( g ) { | FILTER_GROUPS.forEach( function ( g ) { | ||
| Line 312: | Line 402: | ||
filterSection.appendChild( group ); | filterSection.appendChild( group ); | ||
} ); | } ); | ||
if ( glossaryMatches.length ) { | |||
var kwGroup = document.createElement( 'div' ); | |||
kwGroup.className = 'gplx-filter-group'; | |||
var kwLabel = document.createElement( 'span' ); | |||
kwLabel.className = 'gplx-filter-label'; | |||
kwLabel.textContent = 'Keywords'; | |||
var kwRow = document.createElement( 'div' ); | |||
kwRow.className = 'gplx-filter-btn-row'; | |||
glossaryMatches | |||
.slice() | |||
.sort( function ( a, b ) { return a.short.localeCompare( b.short ); } ) | |||
.forEach( function ( g ) { | |||
var btn = document.createElement( 'button' ); | |||
btn.type = 'button'; | |||
btn.className = 'gplx-filter-btn'; | |||
btn.textContent = g.short; | |||
btn.setAttribute( 'aria-pressed', 'false' ); | |||
btn.addEventListener( 'click', function () { | |||
if ( activeFilters.keywords[ g.tagId ] ) { | |||
delete activeFilters.keywords[ g.tagId ]; | |||
btn.classList.remove( 'is-active' ); | |||
btn.setAttribute( 'aria-pressed', 'false' ); | |||
} else { | |||
activeFilters.keywords[ g.tagId ] = true; | |||
btn.classList.add( 'is-active' ); | |||
btn.setAttribute( 'aria-pressed', 'true' ); | |||
} | |||
refresh(); | |||
} ); | |||
kwRow.appendChild( btn ); | |||
} ); | |||
kwGroup.appendChild( kwLabel ); | |||
kwGroup.appendChild( kwRow ); | |||
filterSection.appendChild( kwGroup ); | |||
} | |||
} | } | ||
| Line 317: | Line 447: | ||
function matchesFilters( result ) { | function matchesFilters( result ) { | ||
var standardOk = FILTER_GROUPS.every( function ( g ) { | |||
var active = Object.keys( activeFilters[ g.key ] || {} ); | var active = Object.keys( activeFilters[ g.key ] || {} ); | ||
if ( !active.length ) { return true; } | if ( !active.length ) { return true; } | ||
| Line 328: | Line 458: | ||
} ); | } ); | ||
} ); | } ); | ||
if ( !standardOk ) { return false; } | |||
var activeTagIds = Object.keys( activeFilters.keywords || {} ); | |||
if ( activeTagIds.length ) { | |||
var resultTagIds = result.tagIds || []; | |||
var tagMatch = activeTagIds.some( function ( t ) { | |||
return resultTagIds.indexOf( t ) !== -1; | |||
} ); | |||
if ( !tagMatch ) { return false; } | |||
} | |||
return true; | |||
} | } | ||
| Line 336: | Line 477: | ||
if ( !matchesFilters( r ) ) { return false; } | if ( !matchesFilters( r ) ) { return false; } | ||
if ( text ) { | if ( text ) { | ||
if ( contentMatches[ r.fulltext ] ) { return true; } | |||
var hay = [ | var hay = [ | ||
r.displayName, r.reference, r.validityArea, r.scopes, | r.displayName, r.reference, r.validityArea, r.scopes, | ||
r.docName, r.version, r.status, r.docType, r.language, | r.docName, r.version, r.status, r.docType, r.language, | ||
r.description, r.restricted, r.submittedBy, r.contributors | r.description, r.restricted, r.submittedBy, r.contributors | ||
].join( ' ' ).toLowerCase(); | ].join( ' ' ).toLowerCase(); | ||
if ( hay.indexOf( text ) === -1 ) { return false; } | if ( hay.indexOf( text ) === -1 ) { return false; } | ||
| Line 427: | Line 568: | ||
var restricted = r.restricted && r.restricted.toLowerCase() === 'yes' | var restricted = r.restricted && r.restricted.toLowerCase() === 'yes' | ||
? ' <span class="gplx-badge-restricted">Restricted</span>' : ''; | ? ' <span class="gplx-badge-restricted">Restricted</span>' : ''; | ||
var preview = r.preview | var preview = r.previewLoading | ||
? '<div class="gplx-result-preview">' + esc( r.preview ) + '</div>' : ''; | ? '<div class="gplx-result-preview gplx-result-preview--loading">Preview loading…</div>' | ||
: ( r.preview ? '<div class="gplx-result-preview">' + esc( r.preview ) + '</div>' : '' ); | |||
return '<tr>' | return '<tr>' | ||
+ '<td>' + typeBadge + '</td>' | + '<td>' + typeBadge + '</td>' | ||
| Line 475: | Line 617: | ||
var pageResults = filteredResults.slice( start, end ); | var pageResults = filteredResults.slice( start, end ); | ||
function applyPreviewState( r ) { | |||
r.previewLoading = !( r.fulltext in extractsCache ); | |||
r.preview = extractsCache[ r.fulltext ] || ''; | |||
} | |||
// Render immediately with any already-cached previews; anything not yet | |||
// fetched shows a "Preview loading…" placeholder until the request below resolves. | |||
pageResults.forEach( applyPreviewState ); | |||
renderTable( pageResults ); | renderTable( pageResults ); | ||
renderPagination( total ); | renderPagination( total ); | ||
| Line 483: | Line 631: | ||
fetchPageExtracts( pageResults, token, function ( t ) { | fetchPageExtracts( pageResults, token, function ( t ) { | ||
if ( renderToken !== t ) { return; } | if ( renderToken !== t ) { return; } | ||
pageResults.forEach( | pageResults.forEach( applyPreviewState ); | ||
renderTable( pageResults ); | renderTable( pageResults ); | ||
} ); | } ); | ||
| Line 492: | Line 640: | ||
currentPage = 1; | currentPage = 1; | ||
renderPage(); | renderPage(); | ||
} | |||
/* ---- spinner ---- */ | |||
function showSpinner() { | |||
var el = document.getElementById( 'gplx-search-spinner' ); | |||
if ( el ) { el.style.display = ''; } | |||
} | |||
function hideSpinner() { | |||
var el = document.getElementById( 'gplx-search-spinner' ); | |||
if ( el ) { el.style.display = 'none'; } | |||
} | } | ||
| Line 504: | Line 664: | ||
clearBtn.className = 'gplx-filter-clear'; | clearBtn.className = 'gplx-filter-clear'; | ||
clearBtn.textContent = 'Clear all filters'; | clearBtn.textContent = 'Clear all filters'; | ||
clearBtn.style.display = 'none'; | |||
clearBtn.addEventListener( 'click', function () { | clearBtn.addEventListener( 'click', function () { | ||
FILTER_GROUPS.forEach( function ( g ) { activeFilters[ g.key ] = {}; } ); | FILTER_GROUPS.forEach( function ( g ) { activeFilters[ g.key ] = {}; } ); | ||
activeFilters.keywords = {}; | |||
document.querySelectorAll( '.gplx-filter-btn' ).forEach( function ( b ) { | document.querySelectorAll( '.gplx-filter-btn' ).forEach( function ( b ) { | ||
b.classList.remove( 'is-active' ); | b.classList.remove( 'is-active' ); | ||
| Line 516: | Line 678: | ||
searchLabel.className = 'gplx-filter-label'; | searchLabel.className = 'gplx-filter-label'; | ||
searchLabel.htmlFor = 'gplx-filter-text'; | searchLabel.htmlFor = 'gplx-filter-text'; | ||
searchLabel.textContent = 'Search in titles and | searchLabel.textContent = 'Search in titles, metadata and page content'; | ||
var textInput = document.createElement( 'input' ); | var textInput = document.createElement( 'input' ); | ||
| Line 523: | Line 685: | ||
textInput.className = 'gplx-filter-input'; | textInput.className = 'gplx-filter-input'; | ||
textInput.placeholder = 'Search…'; | textInput.placeholder = 'Search…'; | ||
textInput.setAttribute( 'aria-label', 'Search in titles and | textInput.setAttribute( 'aria-label', 'Search in titles, metadata and page content' ); | ||
var searchBtn = document.createElement( 'button' ); | |||
searchBtn.type = 'button'; | |||
searchBtn.id = 'gplx-search-btn'; | |||
searchBtn.className = 'gplx-filter-clear gplx-search-btn'; | |||
searchBtn.textContent = 'Search'; | |||
var spinner = document.createElement( 'span' ); | |||
spinner.id = 'gplx-search-spinner'; | |||
spinner.className = 'gplx-spinner'; | |||
spinner.style.display = 'none'; | |||
spinner.setAttribute( 'aria-hidden', 'true' ); | |||
var searchRow = document.createElement( 'div' ); | |||
searchRow.className = 'gplx-search-input-row'; | |||
searchRow.appendChild( textInput ); | |||
searchRow.appendChild( searchBtn ); | |||
searchRow.appendChild( spinner ); | |||
var searchGroup = document.createElement( 'div' ); | var searchGroup = document.createElement( 'div' ); | ||
| Line 529: | Line 709: | ||
searchGroup.style.marginBottom = '0.5rem'; | searchGroup.style.marginBottom = '0.5rem'; | ||
searchGroup.appendChild( searchLabel ); | searchGroup.appendChild( searchLabel ); | ||
searchGroup.appendChild( | searchGroup.appendChild( searchRow ); | ||
var status = document.createElement( 'p' ); | var status = document.createElement( 'p' ); | ||
| Line 537: | Line 717: | ||
var results = document.createElement( 'div' ); | var results = document.createElement( 'div' ); | ||
results.id = 'gplx-search-results'; | results.id = 'gplx-search-results'; | ||
results.innerHTML = '<p class="gplx-no-results">Enter a search term and press Enter, or click Search to browse all documents and references.</p>'; | |||
var pagination = document.createElement( 'nav' ); | var pagination = document.createElement( 'nav' ); | ||
| Line 544: | Line 725: | ||
container.innerHTML = ''; | container.innerHTML = ''; | ||
container.appendChild( searchGroup ); | |||
container.appendChild( filterSection ); | container.appendChild( filterSection ); | ||
container.appendChild( clearBtn ); | container.appendChild( clearBtn ); | ||
container.appendChild( status ); | container.appendChild( status ); | ||
container.appendChild( results ); | container.appendChild( results ); | ||
container.appendChild( pagination ); | container.appendChild( pagination ); | ||
return { filterSection: filterSection, textInput: textInput }; | return { filterSection: filterSection, textInput: textInput, searchBtn: searchBtn, clearBtn: clearBtn }; | ||
} | } | ||
/* ---- load data ---- */ | /* ---- load data ---- | ||
* Document/Reference/Glossary metadata (DOC_QUERY/REF_QUERY/GLOSSARY_QUERY) doesn't | |||
* change between searches within the same page visit, so it's fetched once and | |||
* cached in allResults. Only the content-search API call re-runs per search. */ | |||
var metadataLoaded = false; | |||
function loadMetadata( controls, callback ) { | |||
var api = new mw.Api(); | |||
var docResults = [], refOwnRows = [], docRefsMap = {}, glossaryResults = []; | |||
var docDone = false, refOwnDone = false, docRefsDone = false, glossaryDone = false; | |||
function onAllDone() { | |||
if ( !( docDone && refOwnDone && docRefsDone && glossaryDone ) ) { return; } | |||
var docsByTitle = {}; | |||
docResults.forEach( function ( d ) { docsByTitle[ d.fulltext ] = d; } ); | |||
var refResults = buildReferenceResults( refOwnRows, docsByTitle, docRefsMap ); | |||
allResults = docResults.concat( refResults ); | allResults = docResults.concat( refResults ); | ||
var values = collectFieldValues( allResults ); | var values = collectFieldValues( allResults ); | ||
var tagIdSet = {}; | |||
refResults.forEach( function ( r ) { | |||
} | ( r.tagIds || [] ).forEach( function ( t ) { tagIdSet[ t ] = true; } ); | ||
} ); | |||
var glossaryMatches = glossaryResults.filter( function ( g ) { | |||
return tagIdSet[ g.tagId ]; | |||
} ); | |||
renderFilterButtons( controls.filterSection, values, glossaryMatches, controls.clearBtn ); | |||
metadataLoaded = true; | |||
callback(); | |||
} | } | ||
api.get( { action: 'ask', query: DOC_QUERY, format: 'json' } ) | api.get( { action: 'ask', query: DOC_QUERY, format: 'json' } ) | ||
.always( function ( data ) { | .always( function ( data ) { | ||
if ( data && data.query ) { docResults = | if ( data && data.query ) { docResults = parseDocuments( data ); } | ||
docDone = true; | docDone = true; | ||
onAllDone(); | |||
} ); | } ); | ||
api.get( { action: 'ask', query: REF_QUERY, format: 'json' } ) | api.get( { action: 'ask', query: REF_QUERY, format: 'json' } ) | ||
.always( function ( data ) { | .always( function ( data ) { | ||
if ( data && data.query ) { | if ( data && data.query ) { refOwnRows = parseReferenceOwnProps( data ); } | ||
refOwnDone = true; | |||
if ( | onAllDone(); | ||
} ); | |||
api.get( { action: 'ask', query: DOC_REFS_QUERY, format: 'json' } ) | |||
.always( function ( data ) { | |||
if ( data && data.query ) { docRefsMap = parseDocRefsMap( data ); } | |||
docRefsDone = true; | |||
onAllDone(); | |||
} ); | |||
api.get( { action: 'ask', query: GLOSSARY_QUERY, format: 'json' } ) | |||
.always( function ( data ) { | |||
if ( data && data.query ) { glossaryResults = parseGlossary( data ); } | |||
glossaryDone = true; | |||
onAllDone(); | |||
} ); | |||
} | |||
function runContentSearch( callback ) { | |||
var textEl = document.getElementById( 'gplx-filter-text' ); | |||
var text = textEl ? textEl.value.trim() : ''; | |||
contentMatches = {}; | |||
if ( !text ) { callback(); return; } | |||
/* Full-page-content search: uses MediaWiki's own search index (same one | |||
* behind the fulltext inputbox above), so a term anywhere in a page's | |||
* body — not just in the titles/properties checked locally — matches. */ | |||
new mw.Api().get( { | |||
action: 'query', | |||
list: 'search', | |||
srsearch: text, | |||
srnamespace: '3000|3002', | |||
srwhat: 'text', | |||
srlimit: 5000, // MediaWiki silently caps this at each user's actual limit (500 without apihighlimits) | |||
format: 'json' | |||
} ).always( function ( data ) { | |||
var hits = ( data && data.query && data.query.search ) || []; | |||
hits.forEach( function ( hit ) { contentMatches[ hit.title ] = true; } ); | |||
callback(); | |||
} ); | |||
} | |||
function runSearch( controls ) { | |||
showSpinner(); | |||
controls.searchBtn.disabled = true; | |||
setStatus( 'Loading…', true ); | |||
function afterMetadata() { | |||
runContentSearch( function () { | |||
controls.searchBtn.disabled = false; | |||
hideSpinner(); | |||
if ( allResults.length ) { | |||
refresh(); | |||
} else { | |||
setStatus( '', false ); | |||
document.getElementById( 'gplx-search-results' ).innerHTML = | |||
'<p class="gplx-no-results">No documents or references indexed yet. Import data to enable filters.</p>'; | |||
} | |||
} ); | } ); | ||
} | |||
if ( metadataLoaded ) { | |||
afterMetadata(); | |||
} else { | |||
loadMetadata( controls, afterMetadata ); | |||
} | |||
} | } | ||
| Line 596: | Line 859: | ||
if ( !container ) { return; } | if ( !container ) { return; } | ||
var controls = buildUI( container ); | var controls = buildUI( container ); | ||
controls.textInput.addEventListener( ' | function doSearch() { | ||
runSearch( controls ); | |||
} | |||
controls.searchBtn.addEventListener( 'click', doSearch ); | |||
controls.textInput.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' ) { | |||
e.preventDefault(); | |||
doSearch(); | |||
} | |||
} ); | } ); | ||
} ); | |||
} ); | |||
}() ); | |||
/* Special:SpecialPages — add a short description under each link, since core | |||
* MediaWiki only shows the link text itself with no explanation. */ | |||
( function () { | |||
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'Specialpages' ) { return; } | |||
var descriptions = { | |||
// Maintenance reports | |||
'Broken redirects': 'Pages that redirect to a page which no longer exists.', | |||
'Dead-end pages': 'Pages that contain no links to other pages.', | |||
'Double redirects': 'Pages that redirect to another redirect page.', | |||
'Long pages': 'Pages sorted by wikitext size, longest first.', | |||
'Oldest pages': 'Pages sorted by last-edit date, oldest first.', | |||
'Orphaned pages': 'Pages that no other page links to.', | |||
'Pages with the fewest revisions': 'Pages sorted by number of edits, fewest first.', | |||
'Pages without language links': 'Pages missing interlanguage links.', | |||
'Protected pages': 'Pages that are locked against editing or moving.', | |||
'Protected titles': 'Titles that are prevented from being created.', | |||
'Short pages': 'Pages sorted by wikitext size, shortest first.', | |||
'Uncategorized categories': 'Categories that are not themselves in a category.', | |||
'Uncategorized files': 'Uploaded files not assigned to any category.', | |||
'Uncategorized pages': 'Pages not assigned to any category.', | |||
'Uncategorized templates': 'Templates not assigned to any category.', | |||
'Unused categories': 'Categories that exist but contain no pages.', | |||
'Unused files': 'Uploaded files not used on any page.', | |||
'Unused templates': 'Templates not transcluded on any page.', | |||
'Wanted categories': 'Categories that are linked to but do not exist yet.', | |||
'Wanted files': 'Files that are linked to but have not been uploaded.', | |||
'Wanted pages': 'Pages that are linked to but do not exist yet.', | |||
'Wanted templates': 'Templates that are linked to but do not exist yet.', | |||
// Lists of pages | |||
'All pages': 'Alphabetical list of every page in a chosen namespace.', | |||
'All pages with prefix': 'Pages whose title starts with a given prefix.', | |||
'Approved revisions': 'Overview of pages and which revision is currently approved (ApprovedRevs).', | |||
'Categories': 'List of all categories and how many pages are in each.', | |||
'External links search': 'Find pages that link to a given external URL or domain.', | |||
'List of redirects': 'All pages that redirect to another page.', | |||
'Pages with a page property': 'Pages that have a specific technical property set.', | |||
'Search': 'Full-text search across the wiki.', | |||
'Tracking categories': 'System-generated categories used for maintenance tracking.', | |||
// Account management | |||
'Bot passwords': 'Create limited-access passwords for automated logins.', | |||
'Change credentials': 'Change your login credentials.', | |||
'Change or remove email address': 'Update or remove the email address on your account.', | |||
'Log in': 'Sign in to your account.', | |||
'Log out': 'Sign out of your account.', | |||
'Preferences': 'Your personal account settings.', | |||
'Remove credentials': 'Remove a login credential from your account.', | |||
'Reset password': 'Request a password reset.', | |||
'Reset tokens': 'Invalidate saved login tokens for your account.', | |||
// Users and rights | |||
'Active users list': 'Users who have been active recently.', | |||
'Autoblocks': 'IP addresses currently autoblocked because of a blocked user.', | |||
'Blocked users': 'List of currently blocked users and IP addresses.', | |||
'Create account': 'Register a new user account.', | |||
'Email user': 'Send another user an email, if they allow it.', | |||
'Grants': 'Overview of OAuth / API permission grants.', | |||
'Password policies': 'Password strength requirements per user group.', | |||
'User contributions': 'All edits made by a given user.', | |||
'User group rights': 'What each user group (sysop, reviewer, ...) is allowed to do.', | |||
'User list': 'List of all registered users.', | |||
'User rights': 'Change a user’s group membership, e.g. promote to reviewer.', | |||
// Recent changes and logs | |||
'Gallery of new files': 'Thumbnail gallery of recently uploaded files.', | |||
'Logs': 'All administrative actions: blocks, deletions, uploads, approvals, ...', | |||
'New pages': 'Recently created pages.', | |||
'Recent changes': 'Recent edits across the wiki.', | |||
'Related changes': 'Recent edits to pages linked from a given page.', | |||
'Valid change tags': 'Tags that can be attached to edits.', | |||
// Media reports and uploads | |||
'File list': 'All uploaded files.', | |||
'List of files with duplicates': 'Files that have duplicate copies.', | |||
'MIME search': 'Find files by MIME type.', | |||
'Media statistics': 'Counts of uploaded files by type.', | |||
'Search for duplicate files': 'Find files identical to a given file.', | |||
// Data and tools | |||
'API sandbox': 'Try out API requests interactively.', | |||
'Book sources': 'Find a book by its ISBN.', | |||
'Expand templates': 'See wikitext with all templates expanded.', | |||
'Namespace information': 'Overview of all namespaces on the wiki.', | |||
'Statistics': 'Wiki-wide statistics: pages, edits, users.', | |||
'System messages': 'All interface text messages used by MediaWiki.', | |||
'Version': 'Installed MediaWiki, PHP, database and extension versions.', | |||
// Redirecting special pages | |||
'Delete a page': 'Jump to the delete form for a page by name.', | |||
'Differences': 'Jump to a diff between two revisions.', | |||
'Edit a page': 'Jump to the edit form for a page by name.', | |||
'New section': 'Jump to adding a new talk-page section.', | |||
'Page history': 'Jump to a page’s revision history.', | |||
'Page info': 'Jump to a page’s technical information.', | |||
'Permanent link': 'Jump to one specific, fixed revision.', | |||
'Protect a page': 'Jump to the protection form for a page.', | |||
'Purge': 'Clear the cache for a page.', | |||
'Random page': 'Go to a random page.', | |||
'Random page in category': 'Go to a random page within a chosen category.', | |||
'Random redirect': 'Go to a random redirect page.', | |||
'Random root page': 'Go to a random top-level (non-subpage) page.', | |||
'Redirect by file, user, page, revision, or log ID': 'Jump directly to an item by its internal ID.', | |||
// High use pages | |||
'Most linked-to categories': 'Categories used on the most pages.', | |||
'Most linked-to files': 'Files used on the most pages.', | |||
'Most linked-to pages': 'Pages linked to from the most other pages.', | |||
'Most transcluded pages': 'Templates or pages transcluded the most often.', | |||
'Pages with the most categories': 'Pages sorted by number of categories assigned.', | |||
'Pages with the most interwikis': 'Pages with the most interwiki/interlanguage links.', | |||
'Pages with the most revisions': 'Pages sorted by edit count.', | |||
// Page tools | |||
'Compare pages': 'Compare the wikitext of two pages or revisions.', | |||
'Export pages': 'Download pages as a MediaWiki XML file.', | |||
'What links here': 'All pages that link to a given page.', | |||
// Semantic MediaWiki | |||
'Export pages to RDF': 'Download semantic data as RDF.', | |||
'Browse wiki': 'Browse a page’s semantic properties and values.', | |||
'Faceted search': 'Filter pages by their semantic properties.', | |||
'Search by property': 'Find pages by a specific property value.', | |||
'Semantic search': 'Run a structured #ask query.', | |||
'Concepts': 'Saved, query-defined page collections.', | |||
'Properties': 'List of all Semantic MediaWiki properties.', | |||
'Property label similarity report': 'Detect possibly duplicate or misspelled properties.', | |||
'Types': 'Data types available for properties.', | |||
'Unused properties': 'Properties that were declared but are not used on any page.', | |||
'Wanted properties': 'Properties used on pages but never formally declared.', | |||
'Constraint error list': 'Pages that violate a defined property constraint.', | |||
'Missing redirect annotations': 'Redirects missing their semantic annotation.', | |||
'Processing error list': 'Pages where a semantic query or annotation failed.', | |||
// Page Forms | |||
'Create a category': 'Set up a new category, with an optional form and template.', | |||
'Create a class': 'Set up a linked category, template and form as one unit.', | |||
'Create a form': 'Build a new PageForms form.', | |||
'Create a property': 'Declare a new Semantic MediaWiki property.', | |||
'Create a template': 'Build a new template, optionally with #set: fields.', | |||
'Edit multiple pages': 'Bulk-edit several pages at once via a form.', | |||
'Forms': 'List of all PageForms forms on the wiki.', | |||
'Run query': 'Build and run a semantic query interactively.', | |||
'Start of form': 'Pick a form and a page name to start creating a page.', | |||
'Templates': 'List of all templates and the forms that edit them.', | |||
// Other special pages | |||
'Contribute': 'Overview of your contributions and ratings.', | |||
'Top rated pages': 'Pages ranked by user rating.' | |||
}; | |||
$( function () { | |||
var links = document.querySelectorAll( '.mw-specialpages-list li a' ); | |||
links.forEach( function ( a ) { | |||
var desc = descriptions[ a.textContent.trim() ]; | |||
if ( !desc ) { return; } | |||
var descEl = document.createElement( 'div' ); | |||
descEl.className = 'gplx-sp-desc'; | |||
descEl.textContent = desc; | |||
a.parentNode.appendChild( descEl ); | |||
} ); | } ); | ||
} ); | } ); | ||
}() ); | }() ); | ||
Latest revision as of 17:55, 19 July 2026
/* site-wide JS — VoteNY toggle is handled in Vote.js */
/* Document: / Reference: page layout
*
* On Document: (ns 3000) and Reference: (ns 3002) pages:
* – Moves all .gplx-infobox tables into a right-hand sidebar column.
* – On Reference: pages additionally:
* • Removes the redundant "Document Information" h2.
* • Converts the raw "Reference Details" wikitable to gplx-infobox style
* and moves it into the sidebar.
* • Renames the "Text" section heading to "Content".
* – Wraps sidebar + main content in a CSS-flex two-column layout.
*/
( function () {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns !== 3000 && ns !== 3002 ) { return; }
$( function () {
var output = document.querySelector( '.mw-parser-output' );
if ( !output ) { return; }
function findH2ById( id ) {
var headings = output.querySelectorAll( 'h2' );
for ( var i = 0; i < headings.length; i++ ) {
var span = headings[ i ].querySelector( '.mw-headline' );
if ( span && span.id === id ) { return headings[ i ]; }
}
return null;
}
var sidebar = document.createElement( 'div' );
sidebar.className = 'gplx-sidebar';
function pinToSidebar( table ) {
table.style.setProperty( 'float', 'none', 'important' );
table.style.setProperty( 'clear', 'none', 'important' );
table.style.setProperty( 'margin', '0 0 1rem 0', 'important' );
table.style.setProperty( 'width', '100%', 'important' );
sidebar.appendChild( table );
}
/* --- Move all gplx-infobox tables to sidebar --- */
Array.from( output.querySelectorAll( 'table.gplx-infobox' ) ).forEach( pinToSidebar );
/* --- Reference: page extras --- */
if ( ns === 3002 ) {
/* Remove "Document Information" h2 (table header is sufficient) */
var docInfoH2 = findH2ById( 'Document_Information' );
if ( docInfoH2 ) { docInfoH2.remove(); }
/* Convert Reference Details wikitable to gplx-infobox style */
var refDetailsH2 = findH2ById( 'Reference_Details' );
if ( refDetailsH2 ) {
var refTable = refDetailsH2.nextElementSibling;
if ( refTable && refTable.tagName === 'TABLE' ) {
refTable.classList.add( 'wikitable', 'gplx-infobox', 'gplx-ref-details' );
refTable.removeAttribute( 'style' );
refTable.querySelectorAll( 'th[style]' ).forEach( function ( th ) {
th.removeAttribute( 'style' );
} );
/* Prepend header row identical to gplx-infobox-header */
var tbody = refTable.querySelector( 'tbody' ) || refTable;
var headerRow = document.createElement( 'tr' );
var headerCell = document.createElement( 'th' );
headerCell.colSpan = 2;
headerCell.className = 'gplx-infobox-header';
headerCell.textContent = 'Reference details';
headerRow.appendChild( headerCell );
tbody.insertBefore( headerRow, tbody.firstChild );
pinToSidebar( refTable );
}
refDetailsH2.remove();
}
/* Rename "Text" section heading to "Content" */
var textH2 = findH2ById( 'Text' );
if ( textH2 ) {
var span = textH2.querySelector( '.mw-headline' );
if ( span ) {
span.textContent = 'Content';
span.id = 'Content';
}
}
}
/* --- Wrap in two-column flex layout --- */
var layout = document.createElement( 'div' );
layout.className = 'gplx-page-layout';
var main = document.createElement( 'div' );
main.className = 'gplx-main';
while ( output.firstChild ) {
main.appendChild( output.firstChild );
}
layout.appendChild( main );
layout.appendChild( sidebar );
output.appendChild( layout );
} );
}() );
/* GxPlex Search — chainable filter buttons with pagination */
( function () {
if ( mw.config.get( 'wgPageName' ) !== 'GxPlex_Search' ) {
return;
}
var PAGE_SIZE = 50;
var FILTER_GROUPS = [
{ key: 'type', label: 'Type' },
{ key: 'validityArea', label: 'Validity area' },
{ key: 'scopes', label: 'Scope(s)' },
{ key: 'status', label: 'Status' },
{ key: 'docType', label: 'Document type' },
{ key: 'language', label: 'Language' },
{ key: 'restricted', label: 'Restricted access' }
];
var DOC_QUERY = '[[Document:+]]'
+ '|?DocReference=reference'
+ '|?DocValidityArea=validityArea'
+ '|?DocScopes=scopes'
+ '|?DocName=docName'
+ '|?DocVersion=version'
+ '|?DocStatus=status'
+ '|?DocType=docType'
+ '|?DocLanguage=language'
+ '|?DocDescription=description'
+ '|?DocRestricted=restricted'
+ '|?DocSubmittedBy=submittedBy'
+ '|?DocContributors=contributors'
+ '|limit=5000'
+ '|sort=DocName'
+ '|order=asc';
/* References' own properties only — no HasDocument.* joins. Chaining through
* HasDocument costs SMW ~21ms per row (subject-side Page-type resolution isn't
* batched), which is 25s+ at 1181 rows and can hang a single-threaded PHP server.
* The Document-derived fields (validityArea, status, ...) are instead joined
* client-side using DOC_REFS_QUERY below, which asks the same link from the
* Document side (323 subjects) — same data, ~20x cheaper. */
var REF_QUERY = '[[Reference:+]]'
+ '|?RefTags=refTags'
+ '|limit=5000';
/* Cheap inverse of HasDocument: for each Document, which References point to it.
* Used to join Reference rows to their Document's properties client-side. */
var DOC_REFS_QUERY = '[[Document:+]]'
+ '|?-HasDocument=refs'
+ '|limit=5000';
var GLOSSARY_QUERY = '[[Glossary:+]]'
+ '|?GlossaryShort=short'
+ '|?GlossaryTagID=tagId'
+ '|limit=5000';
var allResults = [];
var filteredResults = [];
var currentPage = 1;
var activeFilters = { keywords: {} };
var extractsCache = {}; // fulltext → 250-char preview string
var renderToken = 0; // incremented on every renderPage; cancels stale extract callbacks
var contentMatches = {}; // fulltext → true, for pages whose body matched the last full-text search
/* ---- helpers ---- */
function esc( str ) {
return String( str || '' )
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /"/g, '"' );
}
function getProp( printouts, key ) {
var arr = printouts[ key ];
if ( !arr || !arr.length ) { return ''; }
return arr.map( function ( v ) {
if ( typeof v === 'string' ) { return v; }
return v.item !== undefined ? v.item : ( v.fulltext || '' );
} ).filter( Boolean ).join( ', ' );
}
function splitValues( str ) {
return String( str || '' ).split( /\s*[,;]\s*/ ).map( function ( v ) { return v.trim(); } ).filter( Boolean );
}
function splitTagIds( str ) {
return String( str || '' ).split( '++' ).map( function ( v ) { return v.trim(); } ).filter( Boolean );
}
/* ---- parse SMW responses ---- */
function parseDocuments( data ) {
var raw = ( data.query && data.query.results ) || {};
return Object.keys( raw ).map( function ( key ) {
var page = raw[ key ];
var p = page.printouts || {};
var docName = getProp( p, 'docName' );
return {
_type: 'Document',
fulltext: page.fulltext,
url: page.fullurl,
displayName: docName || page.fulltext.replace( /^Document:/, '' ),
reference: getProp( p, 'reference' ),
validityArea: getProp( p, 'validityArea' ),
scopes: getProp( p, 'scopes' ),
docName: docName,
version: getProp( p, 'version' ),
status: getProp( p, 'status' ),
docType: getProp( p, 'docType' ),
language: getProp( p, 'language' ),
description: getProp( p, 'description' ),
restricted: getProp( p, 'restricted' ),
submittedBy: getProp( p, 'submittedBy' ),
contributors: getProp( p, 'contributors' ),
tagIds: [],
preview: ''
};
} );
}
function parseReferenceOwnProps( data ) {
var raw = ( data.query && data.query.results ) || {};
return Object.keys( raw ).map( function ( key ) {
var page = raw[ key ];
var p = page.printouts || {};
return {
fulltext: page.fulltext,
url: page.fullurl,
tagIds: splitTagIds( getProp( p, 'refTags' ) )
};
} );
}
/* Inverts DOC_REFS_QUERY's Document→[References] into Reference fulltext→Document fulltext. */
function parseDocRefsMap( data ) {
var raw = ( data.query && data.query.results ) || {};
var map = {};
Object.keys( raw ).forEach( function ( docTitle ) {
var refs = ( raw[ docTitle ].printouts && raw[ docTitle ].printouts.refs ) || [];
refs.forEach( function ( ref ) {
if ( ref && ref.fulltext ) { map[ ref.fulltext ] = docTitle; }
} );
} );
return map;
}
function buildReferenceResults( refOwnRows, docsByTitle, docRefsMap ) {
return refOwnRows.map( function ( r ) {
var doc = docsByTitle[ docRefsMap[ r.fulltext ] ] || null;
return {
_type: 'Reference',
fulltext: r.fulltext,
url: r.url,
displayName: r.fulltext.replace( /^Reference:/, '' ),
reference: doc ? doc.reference : '',
validityArea: doc ? doc.validityArea : '',
scopes: doc ? doc.scopes : '',
docName: doc ? doc.docName : '',
version: doc ? doc.version : '',
status: doc ? doc.status : '',
docType: doc ? doc.docType : '',
language: doc ? doc.language : '',
description: doc ? doc.description : '',
restricted: doc ? doc.restricted : '',
submittedBy: doc ? doc.submittedBy : '',
contributors: doc ? doc.contributors : '',
tagIds: r.tagIds,
preview: ''
};
} );
}
function parseGlossary( data ) {
var raw = ( data.query && data.query.results ) || {};
return Object.keys( raw ).map( function ( key ) {
var p = raw[ key ].printouts || {};
return {
short: getProp( p, 'short' ),
tagId: getProp( p, 'tagId' )
};
} ).filter( function ( g ) { return g.short && g.tagId; } );
}
/* ---- text extracts – lazy per page, cached ---- */
// Documents with no real body text still extract this boilerplate ("== References ==" /
// "''No references yet.''" fallback) — not worth showing as a "preview".
var BLANK_EXTRACT_RE = /^references\s+no references yet\.?$/i;
function fetchPageExtracts( results, token, callback ) {
var missing = results.filter( function ( r ) { return !( r.fulltext in extractsCache ); } );
if ( !missing.length ) { callback( token ); return; }
/* One request per title, not a batched multi-title call: TextExtracts silently
* forces exlimit down to 1 whenever exintro isn't set and more than one title is
* requested (ApiQueryExtracts.php), so a batched call here would only ever fill
* in the extract for the first title and silently drop the rest. */
var api = new mw.Api();
var remaining = missing.length;
function onOneDone() {
remaining--;
if ( remaining <= 0 ) { callback( token ); }
}
missing.forEach( function ( r ) {
api.get( {
action: 'query', prop: 'extracts',
// No exintro: these pages front-load a metadata table before any heading, so
// intro-only extraction (text before the first heading) is always empty. The
// actual body text lives after the first section heading.
exchars: 250, explaintext: true, exsectionformat: 'plain',
titles: r.fulltext,
format: 'json'
} ).always( function ( data ) {
var pages = ( data && data.query && data.query.pages ) || {};
var pg = pages[ Object.keys( pages )[ 0 ] ];
var text = ( pg && pg.extract || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 250 );
extractsCache[ r.fulltext ] = BLANK_EXTRACT_RE.test( text ) ? '' : text;
onOneDone();
} );
} );
}
/* ---- filter button data ---- */
function collectFieldValues( results ) {
var values = {};
FILTER_GROUPS.forEach( function ( g ) { values[ g.key ] = {}; } );
results.forEach( function ( r ) {
FILTER_GROUPS.forEach( function ( g ) {
var raw = g.key === 'type' ? r._type : r[ g.key ];
splitValues( raw ).forEach( function ( v ) { values[ g.key ][ v ] = true; } );
} );
} );
return values;
}
/* ---- render filter buttons ---- */
function renderFilterButtons( filterSection, values, glossaryMatches, clearBtn ) {
filterSection.innerHTML = '';
activeFilters = { keywords: {} };
var hasAnyValue = FILTER_GROUPS.some( function ( g ) {
return Object.keys( values[ g.key ] || {} ).length > 0;
} ) || glossaryMatches.length > 0;
if ( !hasAnyValue ) {
var msg = document.createElement( 'p' );
msg.className = 'gplx-no-results';
msg.textContent = 'No documents or references indexed yet. Import data to enable filters.';
filterSection.appendChild( msg );
if ( clearBtn ) { clearBtn.style.display = 'none'; }
return;
}
if ( clearBtn ) { clearBtn.style.display = ''; }
FILTER_GROUPS.forEach( function ( g ) {
activeFilters[ g.key ] = {};
var vals = Object.keys( values[ g.key ] || {} ).sort();
if ( !vals.length ) { return; }
var group = document.createElement( 'div' );
group.className = 'gplx-filter-group';
var label = document.createElement( 'span' );
label.className = 'gplx-filter-label';
label.textContent = g.label;
var row = document.createElement( 'div' );
row.className = 'gplx-filter-btn-row';
vals.forEach( function ( v ) {
var btn = document.createElement( 'button' );
btn.type = 'button';
btn.className = 'gplx-filter-btn';
btn.textContent = v;
btn.setAttribute( 'aria-pressed', 'false' );
btn.addEventListener( 'click', function () {
if ( activeFilters[ g.key ][ v ] ) {
delete activeFilters[ g.key ][ v ];
btn.classList.remove( 'is-active' );
btn.setAttribute( 'aria-pressed', 'false' );
} else {
activeFilters[ g.key ][ v ] = true;
btn.classList.add( 'is-active' );
btn.setAttribute( 'aria-pressed', 'true' );
}
refresh();
} );
row.appendChild( btn );
} );
group.appendChild( label );
group.appendChild( row );
filterSection.appendChild( group );
} );
if ( glossaryMatches.length ) {
var kwGroup = document.createElement( 'div' );
kwGroup.className = 'gplx-filter-group';
var kwLabel = document.createElement( 'span' );
kwLabel.className = 'gplx-filter-label';
kwLabel.textContent = 'Keywords';
var kwRow = document.createElement( 'div' );
kwRow.className = 'gplx-filter-btn-row';
glossaryMatches
.slice()
.sort( function ( a, b ) { return a.short.localeCompare( b.short ); } )
.forEach( function ( g ) {
var btn = document.createElement( 'button' );
btn.type = 'button';
btn.className = 'gplx-filter-btn';
btn.textContent = g.short;
btn.setAttribute( 'aria-pressed', 'false' );
btn.addEventListener( 'click', function () {
if ( activeFilters.keywords[ g.tagId ] ) {
delete activeFilters.keywords[ g.tagId ];
btn.classList.remove( 'is-active' );
btn.setAttribute( 'aria-pressed', 'false' );
} else {
activeFilters.keywords[ g.tagId ] = true;
btn.classList.add( 'is-active' );
btn.setAttribute( 'aria-pressed', 'true' );
}
refresh();
} );
kwRow.appendChild( btn );
} );
kwGroup.appendChild( kwLabel );
kwGroup.appendChild( kwRow );
filterSection.appendChild( kwGroup );
}
}
/* ---- filter logic: AND across groups, OR within group ---- */
function matchesFilters( result ) {
var standardOk = FILTER_GROUPS.every( function ( g ) {
var active = Object.keys( activeFilters[ g.key ] || {} );
if ( !active.length ) { return true; }
var fieldVal = g.key === 'type' ? result._type : ( result[ g.key ] || '' );
var parts = splitValues( fieldVal );
return active.some( function ( av ) {
return parts.some( function ( p ) {
return p.toLowerCase() === av.toLowerCase();
} );
} );
} );
if ( !standardOk ) { return false; }
var activeTagIds = Object.keys( activeFilters.keywords || {} );
if ( activeTagIds.length ) {
var resultTagIds = result.tagIds || [];
var tagMatch = activeTagIds.some( function ( t ) {
return resultTagIds.indexOf( t ) !== -1;
} );
if ( !tagMatch ) { return false; }
}
return true;
}
function applyFilters( results ) {
var textEl = document.getElementById( 'gplx-filter-text' );
var text = textEl ? textEl.value.trim().toLowerCase() : '';
return results.filter( function ( r ) {
if ( !matchesFilters( r ) ) { return false; }
if ( text ) {
if ( contentMatches[ r.fulltext ] ) { return true; }
var hay = [
r.displayName, r.reference, r.validityArea, r.scopes,
r.docName, r.version, r.status, r.docType, r.language,
r.description, r.restricted, r.submittedBy, r.contributors
].join( ' ' ).toLowerCase();
if ( hay.indexOf( text ) === -1 ) { return false; }
}
return true;
} );
}
/* ---- pagination ---- */
function getPageRange( current, total ) {
if ( total <= 7 ) {
var pages = [];
for ( var i = 1; i <= total; i++ ) { pages.push( i ); }
return pages;
}
var result = [ 1 ];
var left = current - 2;
var right = current + 2;
if ( left > 2 ) { result.push( '...' ); }
for ( var j = Math.max( 2, left ); j <= Math.min( total - 1, right ); j++ ) {
result.push( j );
}
if ( right < total - 1 ) { result.push( '...' ); }
result.push( total );
return result;
}
function renderPagination( total ) {
var el = document.getElementById( 'gplx-pagination' );
if ( !el ) { return; }
var totalPages = Math.ceil( total / PAGE_SIZE );
if ( totalPages <= 1 ) { el.innerHTML = ''; return; }
var frag = document.createDocumentFragment();
function pageBtn( label, page, isCurrent, isDisabled ) {
var btn = document.createElement( 'button' );
btn.type = 'button';
btn.className = 'gplx-page-btn' + ( isCurrent ? ' gplx-page-btn--current' : '' );
btn.textContent = label;
btn.disabled = isDisabled || isCurrent;
if ( isCurrent ) { btn.setAttribute( 'aria-current', 'page' ); }
if ( !isDisabled && !isCurrent ) {
btn.addEventListener( 'click', function () {
currentPage = page;
renderPage();
var s = document.getElementById( 'gplx-search-status' );
if ( s ) { s.scrollIntoView( { behavior: 'smooth', block: 'nearest' } ); }
} );
}
return btn;
}
frag.appendChild( pageBtn( '‹ Previous', currentPage - 1, false, currentPage === 1 ) );
getPageRange( currentPage, totalPages ).forEach( function ( p ) {
if ( p === '...' ) {
var span = document.createElement( 'span' );
span.className = 'gplx-page-ellipsis';
span.textContent = '…';
frag.appendChild( span );
} else {
frag.appendChild( pageBtn( String( p ), p, p === currentPage, false ) );
}
} );
frag.appendChild( pageBtn( 'Next ›', currentPage + 1, false, currentPage === totalPages ) );
el.innerHTML = '';
el.appendChild( frag );
}
/* ---- render results table ---- */
function renderTable( pageResults ) {
var container = document.getElementById( 'gplx-search-results' );
if ( !pageResults.length ) {
container.innerHTML = '<p class="gplx-no-results">No results match the selected filters.</p>';
return;
}
var rows = pageResults.map( function ( r ) {
var typeBadge = r._type === 'Document'
? '<span class="gplx-badge-doc">Document</span>'
: '<span class="gplx-badge-ref">Reference</span>';
var restricted = r.restricted && r.restricted.toLowerCase() === 'yes'
? ' <span class="gplx-badge-restricted">Restricted</span>' : '';
var preview = r.previewLoading
? '<div class="gplx-result-preview gplx-result-preview--loading">Preview loading…</div>'
: ( r.preview ? '<div class="gplx-result-preview">' + esc( r.preview ) + '</div>' : '' );
return '<tr>'
+ '<td>' + typeBadge + '</td>'
+ '<td><a href="' + esc( r.url ) + '">' + esc( r.displayName ) + '</a>'
+ restricted + preview + '</td>'
+ '<td>' + esc( r.validityArea ) + '</td>'
+ '<td>' + esc( r.status ) + '</td>'
+ '<td>' + esc( r.language ) + '</td>'
+ '<td>' + esc( r.docType ) + '</td>'
+ '</tr>';
} );
container.innerHTML = '<table class="wikitable sortable gplx-results-table">'
+ '<thead><tr>'
+ '<th>Type</th><th>Title</th>'
+ '<th>Validity area</th><th>Status</th><th>Language</th><th>Document type</th>'
+ '</tr></thead>'
+ '<tbody>' + rows.join( '' ) + '</tbody>'
+ '</table>';
}
function setStatus( msg, busy ) {
var s = document.getElementById( 'gplx-search-status' );
if ( !s ) { return; }
s.textContent = msg;
s.className = 'gplx-search-status' + ( busy ? ' gplx-search-busy' : '' );
}
function renderPage() {
var token = ++renderToken;
var total = filteredResults.length;
var totalPages = Math.ceil( total / PAGE_SIZE );
currentPage = Math.max( 1, Math.min( currentPage, totalPages || 1 ) );
var start = ( currentPage - 1 ) * PAGE_SIZE;
var end = Math.min( start + PAGE_SIZE, total );
if ( total === 0 ) {
setStatus( '0 results', false );
} else {
setStatus(
'Showing ' + ( start + 1 ) + '–' + end + ' of ' + total
+ ' result' + ( total !== 1 ? 's' : '' ),
false
);
}
var pageResults = filteredResults.slice( start, end );
function applyPreviewState( r ) {
r.previewLoading = !( r.fulltext in extractsCache );
r.preview = extractsCache[ r.fulltext ] || '';
}
// Render immediately with any already-cached previews; anything not yet
// fetched shows a "Preview loading…" placeholder until the request below resolves.
pageResults.forEach( applyPreviewState );
renderTable( pageResults );
renderPagination( total );
// Fetch extracts for this page in the background; re-render only if still on this page
fetchPageExtracts( pageResults, token, function ( t ) {
if ( renderToken !== t ) { return; }
pageResults.forEach( applyPreviewState );
renderTable( pageResults );
} );
}
function refresh() {
filteredResults = applyFilters( allResults );
currentPage = 1;
renderPage();
}
/* ---- spinner ---- */
function showSpinner() {
var el = document.getElementById( 'gplx-search-spinner' );
if ( el ) { el.style.display = ''; }
}
function hideSpinner() {
var el = document.getElementById( 'gplx-search-spinner' );
if ( el ) { el.style.display = 'none'; }
}
/* ---- build container UI ---- */
function buildUI( container ) {
var filterSection = document.createElement( 'div' );
filterSection.className = 'gplx-filter-buttons';
var clearBtn = document.createElement( 'button' );
clearBtn.type = 'button';
clearBtn.className = 'gplx-filter-clear';
clearBtn.textContent = 'Clear all filters';
clearBtn.style.display = 'none';
clearBtn.addEventListener( 'click', function () {
FILTER_GROUPS.forEach( function ( g ) { activeFilters[ g.key ] = {}; } );
activeFilters.keywords = {};
document.querySelectorAll( '.gplx-filter-btn' ).forEach( function ( b ) {
b.classList.remove( 'is-active' );
b.setAttribute( 'aria-pressed', 'false' );
} );
refresh();
} );
var searchLabel = document.createElement( 'label' );
searchLabel.className = 'gplx-filter-label';
searchLabel.htmlFor = 'gplx-filter-text';
searchLabel.textContent = 'Search in titles, metadata and page content';
var textInput = document.createElement( 'input' );
textInput.type = 'search';
textInput.id = 'gplx-filter-text';
textInput.className = 'gplx-filter-input';
textInput.placeholder = 'Search…';
textInput.setAttribute( 'aria-label', 'Search in titles, metadata and page content' );
var searchBtn = document.createElement( 'button' );
searchBtn.type = 'button';
searchBtn.id = 'gplx-search-btn';
searchBtn.className = 'gplx-filter-clear gplx-search-btn';
searchBtn.textContent = 'Search';
var spinner = document.createElement( 'span' );
spinner.id = 'gplx-search-spinner';
spinner.className = 'gplx-spinner';
spinner.style.display = 'none';
spinner.setAttribute( 'aria-hidden', 'true' );
var searchRow = document.createElement( 'div' );
searchRow.className = 'gplx-search-input-row';
searchRow.appendChild( textInput );
searchRow.appendChild( searchBtn );
searchRow.appendChild( spinner );
var searchGroup = document.createElement( 'div' );
searchGroup.className = 'gplx-filter-group gplx-filter-group--grow';
searchGroup.style.marginBottom = '0.5rem';
searchGroup.appendChild( searchLabel );
searchGroup.appendChild( searchRow );
var status = document.createElement( 'p' );
status.id = 'gplx-search-status';
status.className = 'gplx-search-status';
var results = document.createElement( 'div' );
results.id = 'gplx-search-results';
results.innerHTML = '<p class="gplx-no-results">Enter a search term and press Enter, or click Search to browse all documents and references.</p>';
var pagination = document.createElement( 'nav' );
pagination.id = 'gplx-pagination';
pagination.className = 'gplx-pagination';
pagination.setAttribute( 'aria-label', 'Results pages' );
container.innerHTML = '';
container.appendChild( searchGroup );
container.appendChild( filterSection );
container.appendChild( clearBtn );
container.appendChild( status );
container.appendChild( results );
container.appendChild( pagination );
return { filterSection: filterSection, textInput: textInput, searchBtn: searchBtn, clearBtn: clearBtn };
}
/* ---- load data ----
* Document/Reference/Glossary metadata (DOC_QUERY/REF_QUERY/GLOSSARY_QUERY) doesn't
* change between searches within the same page visit, so it's fetched once and
* cached in allResults. Only the content-search API call re-runs per search. */
var metadataLoaded = false;
function loadMetadata( controls, callback ) {
var api = new mw.Api();
var docResults = [], refOwnRows = [], docRefsMap = {}, glossaryResults = [];
var docDone = false, refOwnDone = false, docRefsDone = false, glossaryDone = false;
function onAllDone() {
if ( !( docDone && refOwnDone && docRefsDone && glossaryDone ) ) { return; }
var docsByTitle = {};
docResults.forEach( function ( d ) { docsByTitle[ d.fulltext ] = d; } );
var refResults = buildReferenceResults( refOwnRows, docsByTitle, docRefsMap );
allResults = docResults.concat( refResults );
var values = collectFieldValues( allResults );
var tagIdSet = {};
refResults.forEach( function ( r ) {
( r.tagIds || [] ).forEach( function ( t ) { tagIdSet[ t ] = true; } );
} );
var glossaryMatches = glossaryResults.filter( function ( g ) {
return tagIdSet[ g.tagId ];
} );
renderFilterButtons( controls.filterSection, values, glossaryMatches, controls.clearBtn );
metadataLoaded = true;
callback();
}
api.get( { action: 'ask', query: DOC_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { docResults = parseDocuments( data ); }
docDone = true;
onAllDone();
} );
api.get( { action: 'ask', query: REF_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { refOwnRows = parseReferenceOwnProps( data ); }
refOwnDone = true;
onAllDone();
} );
api.get( { action: 'ask', query: DOC_REFS_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { docRefsMap = parseDocRefsMap( data ); }
docRefsDone = true;
onAllDone();
} );
api.get( { action: 'ask', query: GLOSSARY_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { glossaryResults = parseGlossary( data ); }
glossaryDone = true;
onAllDone();
} );
}
function runContentSearch( callback ) {
var textEl = document.getElementById( 'gplx-filter-text' );
var text = textEl ? textEl.value.trim() : '';
contentMatches = {};
if ( !text ) { callback(); return; }
/* Full-page-content search: uses MediaWiki's own search index (same one
* behind the fulltext inputbox above), so a term anywhere in a page's
* body — not just in the titles/properties checked locally — matches. */
new mw.Api().get( {
action: 'query',
list: 'search',
srsearch: text,
srnamespace: '3000|3002',
srwhat: 'text',
srlimit: 5000, // MediaWiki silently caps this at each user's actual limit (500 without apihighlimits)
format: 'json'
} ).always( function ( data ) {
var hits = ( data && data.query && data.query.search ) || [];
hits.forEach( function ( hit ) { contentMatches[ hit.title ] = true; } );
callback();
} );
}
function runSearch( controls ) {
showSpinner();
controls.searchBtn.disabled = true;
setStatus( 'Loading…', true );
function afterMetadata() {
runContentSearch( function () {
controls.searchBtn.disabled = false;
hideSpinner();
if ( allResults.length ) {
refresh();
} else {
setStatus( '', false );
document.getElementById( 'gplx-search-results' ).innerHTML =
'<p class="gplx-no-results">No documents or references indexed yet. Import data to enable filters.</p>';
}
} );
}
if ( metadataLoaded ) {
afterMetadata();
} else {
loadMetadata( controls, afterMetadata );
}
}
/* ---- init ---- */
mw.loader.using( [ 'mediawiki.api' ], function () {
$( function () {
var container = document.getElementById( 'gplx-search-app' );
if ( !container ) { return; }
var controls = buildUI( container );
function doSearch() {
runSearch( controls );
}
controls.searchBtn.addEventListener( 'click', doSearch );
controls.textInput.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' ) {
e.preventDefault();
doSearch();
}
} );
} );
} );
}() );
/* Special:SpecialPages — add a short description under each link, since core
* MediaWiki only shows the link text itself with no explanation. */
( function () {
if ( mw.config.get( 'wgCanonicalSpecialPageName' ) !== 'Specialpages' ) { return; }
var descriptions = {
// Maintenance reports
'Broken redirects': 'Pages that redirect to a page which no longer exists.',
'Dead-end pages': 'Pages that contain no links to other pages.',
'Double redirects': 'Pages that redirect to another redirect page.',
'Long pages': 'Pages sorted by wikitext size, longest first.',
'Oldest pages': 'Pages sorted by last-edit date, oldest first.',
'Orphaned pages': 'Pages that no other page links to.',
'Pages with the fewest revisions': 'Pages sorted by number of edits, fewest first.',
'Pages without language links': 'Pages missing interlanguage links.',
'Protected pages': 'Pages that are locked against editing or moving.',
'Protected titles': 'Titles that are prevented from being created.',
'Short pages': 'Pages sorted by wikitext size, shortest first.',
'Uncategorized categories': 'Categories that are not themselves in a category.',
'Uncategorized files': 'Uploaded files not assigned to any category.',
'Uncategorized pages': 'Pages not assigned to any category.',
'Uncategorized templates': 'Templates not assigned to any category.',
'Unused categories': 'Categories that exist but contain no pages.',
'Unused files': 'Uploaded files not used on any page.',
'Unused templates': 'Templates not transcluded on any page.',
'Wanted categories': 'Categories that are linked to but do not exist yet.',
'Wanted files': 'Files that are linked to but have not been uploaded.',
'Wanted pages': 'Pages that are linked to but do not exist yet.',
'Wanted templates': 'Templates that are linked to but do not exist yet.',
// Lists of pages
'All pages': 'Alphabetical list of every page in a chosen namespace.',
'All pages with prefix': 'Pages whose title starts with a given prefix.',
'Approved revisions': 'Overview of pages and which revision is currently approved (ApprovedRevs).',
'Categories': 'List of all categories and how many pages are in each.',
'External links search': 'Find pages that link to a given external URL or domain.',
'List of redirects': 'All pages that redirect to another page.',
'Pages with a page property': 'Pages that have a specific technical property set.',
'Search': 'Full-text search across the wiki.',
'Tracking categories': 'System-generated categories used for maintenance tracking.',
// Account management
'Bot passwords': 'Create limited-access passwords for automated logins.',
'Change credentials': 'Change your login credentials.',
'Change or remove email address': 'Update or remove the email address on your account.',
'Log in': 'Sign in to your account.',
'Log out': 'Sign out of your account.',
'Preferences': 'Your personal account settings.',
'Remove credentials': 'Remove a login credential from your account.',
'Reset password': 'Request a password reset.',
'Reset tokens': 'Invalidate saved login tokens for your account.',
// Users and rights
'Active users list': 'Users who have been active recently.',
'Autoblocks': 'IP addresses currently autoblocked because of a blocked user.',
'Blocked users': 'List of currently blocked users and IP addresses.',
'Create account': 'Register a new user account.',
'Email user': 'Send another user an email, if they allow it.',
'Grants': 'Overview of OAuth / API permission grants.',
'Password policies': 'Password strength requirements per user group.',
'User contributions': 'All edits made by a given user.',
'User group rights': 'What each user group (sysop, reviewer, ...) is allowed to do.',
'User list': 'List of all registered users.',
'User rights': 'Change a user’s group membership, e.g. promote to reviewer.',
// Recent changes and logs
'Gallery of new files': 'Thumbnail gallery of recently uploaded files.',
'Logs': 'All administrative actions: blocks, deletions, uploads, approvals, ...',
'New pages': 'Recently created pages.',
'Recent changes': 'Recent edits across the wiki.',
'Related changes': 'Recent edits to pages linked from a given page.',
'Valid change tags': 'Tags that can be attached to edits.',
// Media reports and uploads
'File list': 'All uploaded files.',
'List of files with duplicates': 'Files that have duplicate copies.',
'MIME search': 'Find files by MIME type.',
'Media statistics': 'Counts of uploaded files by type.',
'Search for duplicate files': 'Find files identical to a given file.',
// Data and tools
'API sandbox': 'Try out API requests interactively.',
'Book sources': 'Find a book by its ISBN.',
'Expand templates': 'See wikitext with all templates expanded.',
'Namespace information': 'Overview of all namespaces on the wiki.',
'Statistics': 'Wiki-wide statistics: pages, edits, users.',
'System messages': 'All interface text messages used by MediaWiki.',
'Version': 'Installed MediaWiki, PHP, database and extension versions.',
// Redirecting special pages
'Delete a page': 'Jump to the delete form for a page by name.',
'Differences': 'Jump to a diff between two revisions.',
'Edit a page': 'Jump to the edit form for a page by name.',
'New section': 'Jump to adding a new talk-page section.',
'Page history': 'Jump to a page’s revision history.',
'Page info': 'Jump to a page’s technical information.',
'Permanent link': 'Jump to one specific, fixed revision.',
'Protect a page': 'Jump to the protection form for a page.',
'Purge': 'Clear the cache for a page.',
'Random page': 'Go to a random page.',
'Random page in category': 'Go to a random page within a chosen category.',
'Random redirect': 'Go to a random redirect page.',
'Random root page': 'Go to a random top-level (non-subpage) page.',
'Redirect by file, user, page, revision, or log ID': 'Jump directly to an item by its internal ID.',
// High use pages
'Most linked-to categories': 'Categories used on the most pages.',
'Most linked-to files': 'Files used on the most pages.',
'Most linked-to pages': 'Pages linked to from the most other pages.',
'Most transcluded pages': 'Templates or pages transcluded the most often.',
'Pages with the most categories': 'Pages sorted by number of categories assigned.',
'Pages with the most interwikis': 'Pages with the most interwiki/interlanguage links.',
'Pages with the most revisions': 'Pages sorted by edit count.',
// Page tools
'Compare pages': 'Compare the wikitext of two pages or revisions.',
'Export pages': 'Download pages as a MediaWiki XML file.',
'What links here': 'All pages that link to a given page.',
// Semantic MediaWiki
'Export pages to RDF': 'Download semantic data as RDF.',
'Browse wiki': 'Browse a page’s semantic properties and values.',
'Faceted search': 'Filter pages by their semantic properties.',
'Search by property': 'Find pages by a specific property value.',
'Semantic search': 'Run a structured #ask query.',
'Concepts': 'Saved, query-defined page collections.',
'Properties': 'List of all Semantic MediaWiki properties.',
'Property label similarity report': 'Detect possibly duplicate or misspelled properties.',
'Types': 'Data types available for properties.',
'Unused properties': 'Properties that were declared but are not used on any page.',
'Wanted properties': 'Properties used on pages but never formally declared.',
'Constraint error list': 'Pages that violate a defined property constraint.',
'Missing redirect annotations': 'Redirects missing their semantic annotation.',
'Processing error list': 'Pages where a semantic query or annotation failed.',
// Page Forms
'Create a category': 'Set up a new category, with an optional form and template.',
'Create a class': 'Set up a linked category, template and form as one unit.',
'Create a form': 'Build a new PageForms form.',
'Create a property': 'Declare a new Semantic MediaWiki property.',
'Create a template': 'Build a new template, optionally with #set: fields.',
'Edit multiple pages': 'Bulk-edit several pages at once via a form.',
'Forms': 'List of all PageForms forms on the wiki.',
'Run query': 'Build and run a semantic query interactively.',
'Start of form': 'Pick a form and a page name to start creating a page.',
'Templates': 'List of all templates and the forms that edit them.',
// Other special pages
'Contribute': 'Overview of your contributions and ratings.',
'Top rated pages': 'Pages ranked by user rating.'
};
$( function () {
var links = document.querySelectorAll( '.mw-specialpages-list li a' );
links.forEach( function ( a ) {
var desc = descriptions[ a.textContent.trim() ];
if ( !desc ) { return; }
var descEl = document.createElement( 'div' );
descEl.className = 'gplx-sp-desc';
descEl.textContent = desc;
a.parentNode.appendChild( descEl );
} );
} );
}() );