Personal tools

Sponsorname

Your company could advertise here

www.fla-consulting.eu

MediaWiki:Common.js: Difference between revisions

From GxPlex

Jump to: navigation, search
Add Topic column to search results
Rewrite search: filter buttons, Document/Reference namespaces, text preview
Line 1: Line 1:
/* site-wide JS β€” VoteNY toggle is handled in Vote.js */
/* site-wide JS β€” VoteNY toggle is handled in Vote.js */


/* GxPlex Search β€” dynamic filter UI */
/* GxPlex Search β€” chainable filter buttons */
( function () {
( function () {
if ( mw.config.get( 'wgPageName' ) !== 'GxPlex_Search' ) {
if ( mw.config.get( 'wgPageName' ) !== 'GxPlex_Search' ) {
Line 7: Line 7:
}
}


var TOPICS = [
/* Filter dimensions shown as button groups.
'Medical Devices',
* Free-text fields (description, contributors, version) are excluded here
'In Vitro Diagnostics',
* but remain searchable via the text input. */
'EU Regulation',
var FILTER_GROUPS = [
'Clinical',
{ key: 'type',Β  Β  Β  Β  label: 'Type' },
'Labelling',
{ key: 'validityArea', label: 'Validity area' },
'Post-Market Surveillance',
{ key: 'scopes',Β  Β  Β  label: 'Scope(s)' },
'Performance Evaluation',
{ key: 'status',Β  Β  Β  label: 'Status' },
'Traceability'
{ key: 'docType',Β  Β  label: 'Document type' },
{ key: 'language',Β  Β  label: 'Language' },
{ key: 'restricted',Β  label: 'Restricted access' },
{ key: 'submittedBy', label: 'Submitted by' }
];
];
/* SMW property names must match the {{#set:}} calls on Document/Reference pages. */
var DOC_QUERY = '[[Document:+]]'
+ '|?DocValidityArea=validityArea'
+ '|?DocScopes=scopes'
+ '|?DocName=docName'
+ '|?DocVersion=version'
+ '|?DocStatus=status'
+ '|?DocType=docType'
+ '|?DocLanguage=language'
+ '|?DocDescription=description'
+ '|?DocRestricted=restricted'
+ '|?DocSubmittedBy=submittedBy'
+ '|limit=500'
+ '|sort=DocName'
+ '|order=asc';
/* Chained properties (HasDocument.Xyz) allow filtering References
* by their parent document's metadata. */
var REF_QUERY = '[[Reference:+]]'
+ '|?HasDocument=linkedDoc'
+ '|?HasDocument.DocValidityArea=validityArea'
+ '|?HasDocument.DocScopes=scopes'
+ '|?HasDocument.DocStatus=status'
+ '|?HasDocument.DocType=docType'
+ '|?HasDocument.DocLanguage=language'
+ '|?HasDocument.DocRestricted=restricted'
+ '|?HasDocument.DocSubmittedBy=submittedBy'
+ '|?RefScope=refScope'
+ '|?RefLanguage=refLanguage'
+ '|?RefTagsEN=tagsEN'
+ '|limit=500';


var allResults = [];
var allResults = [];
/* activeFilters: { groupKey: { value: true } } β€” plain objects, no Set dependency */
var activeFilters = {};


/* ---- helpers ---- */
/* ---- helpers ---- */
Line 28: Line 65:
.replace( />/g, '>' )
.replace( />/g, '>' )
.replace( /"/g, '"' );
.replace( /"/g, '"' );
}
function el( tag, attrs, children ) {
var node = document.createElement( tag );
Object.keys( attrs || {} ).forEach( function ( k ) {
node.setAttribute( k, attrs[ k ] );
} );
( children || [] ).forEach( function ( c ) {
node.appendChild( typeof c === 'string' ? document.createTextNode( c ) : c );
} );
return node;
}
}


Line 50: Line 76:
}
}


/* ---- build UI ---- */
function splitValues( str ) {
return str.split( ',' ).map( function ( v ) { return v.trim(); } ).filter( Boolean );
}


function buildUI( container ) {
/* ---- parse SMW responses ---- */
/* filters row */
var topicSel = el( 'select', { id: 'gplx-filter-topic', class: 'gplx-filter-select' } );
[ [ '', 'All topics' ] ].concat( TOPICS.map( function ( t ) { return [ t, t ]; } ) )
.forEach( function ( pair ) {
topicSel.appendChild( el( 'option', { value: pair[ 0 ] }, [ pair[ 1 ] ] ) );
} );


var regionSel = el( 'select', { id: 'gplx-filter-region', class: 'gplx-filter-select' } );
function parseResults( data, type ) {
regionSel.appendChild( el( 'option', { value: '' }, [ 'All regions' ] ) );
var raw = ( data.query && data.query.results ) || {};
return Object.keys( raw ).map( function ( key ) {
var page = raw[ key ];
var p = page.printouts || {};
return {
_type: type,
fulltext: page.fulltext,
url: page.fullurl,
displayName: type === 'Document'
? ( getProp( p, 'docName' ) || page.fulltext.replace( /^Document:/, '' ) )
: page.fulltext.replace( /^Reference:/, '' ),
validityArea: getProp( p, 'validityArea' ),
scopes:Β  Β  Β  getProp( p, 'scopes' ),
status: Β  Β  Β  getProp( p, 'status' ),
docType: Β  Β  getProp( p, 'docType' ),
language:Β  Β  getProp( p, 'language' ),
restricted:Β  getProp( p, 'restricted' ),
submittedBy: getProp( p, 'submittedBy' ),
description:Β  getProp( p, 'description' ),
preview: ''
};
} );
}


var textInput = el( 'input', {
/* ---- text extracts (250 chars, batches of 50) ---- */
type: 'search',
id: 'gplx-filter-text',
class: 'gplx-filter-input',
placeholder: 'Search…'
} );


var filters = el( 'div', { class: 'gplx-search-filters' }, [
function fetchExtracts( results, callback ) {
el( 'div', { class: 'gplx-filter-group' }, [
if ( !results.length ) { callback(); return; }
el( 'label', { class: 'gplx-filter-label', for: 'gplx-filter-topic' }, [ 'Topic' ] ),
topicSel
] ),
el( 'div', { class: 'gplx-filter-group' }, [
el( 'label', { class: 'gplx-filter-label', for: 'gplx-filter-region' }, [ 'Region' ] ),
regionSel
] ),
el( 'div', { class: 'gplx-filter-group gplx-filter-group--grow' }, [
el( 'label', { class: 'gplx-filter-label', for: 'gplx-filter-text' }, [ 'Search' ] ),
textInput
] )
] );


var status = el( 'p', { id: 'gplx-search-status', class: 'gplx-search-status' } );
var byTitle = {};
var results = el( 'div', { id: 'gplx-search-results' } );
results.forEach( function ( r ) { byTitle[ r.fulltext ] = r; } );


container.innerHTML = '';
var titles = results.map( function ( r ) { return r.fulltext; } );
container.appendChild( filters );
var batches = [];
container.appendChild( status );
for ( var i = 0; i < titles.length; i += 50 ) {
container.appendChild( results );
batches.push( titles.slice( i, i + 50 ) );
}


return { topicSel: topicSel, regionSel: regionSel, textInput: textInput };
var done = 0;
var api = new mw.Api();
batches.forEach( function ( batch ) {
api.get( {
action: 'query',
prop: 'extracts',
exchars: 250,
exintro: true,
explaintext: true,
titles: batch.join( '|' ),
format: 'json'
} ).always( function ( data ) {
if ( data && data.query && data.query.pages ) {
Object.keys( data.query.pages ).forEach( function ( id ) {
var pg = data.query.pages[ id ];
if ( byTitle[ pg.title ] ) {
byTitle[ pg.title ].preview = ( pg.extract || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 250 );
}
} );
}
done++;
if ( done === batches.length ) { callback(); }
} );
} );
}
}


/* ---- data ---- */
/* ---- build filter button UI ---- */


function buildQuery( topic ) {
function collectFieldValues( results ) {
return ( topic ? '[[Category:' + topic + ']]' : '' )
var values = {};
+ '[[Status::+]]'
FILTER_GROUPS.forEach( function ( g ) { values[ g.key ] = {}; } );
+ '|?Document name=Document'
results.forEach( function ( r ) {
+ '|?Area of validity=Region'
FILTER_GROUPS.forEach( function ( g ) {
+ '|?Document type=Type'
var raw = g.key === 'type' ? r._type : r[ g.key ];
+ '|?Scope'
splitValues( raw ).forEach( function ( v ) {
+ '|?Language'
values[ g.key ][ v ] = true;
+ '|?Restricted access=Restricted'
} );
+ '|?Category'
} );
+ '|limit=500'
} );
+ '|sort=Document name'
return values;
+ '|order=asc';
}
}


function parseResponse( data ) {
function renderFilterButtons( filterSection, values ) {
var raw = ( data.query && data.query.results ) || {};
filterSection.innerHTML = '';
return Object.keys( raw ).map( function ( key ) {
activeFilters = {};
var page = raw[ key ];
FILTER_GROUPS.forEach( function ( g ) {
var p = page.printouts || {};
activeFilters[ g.key ] = {};
var cats = ( p.Category || [] )
var vals = Object.keys( values[ g.key ] ).sort();
.map( function ( v ) {
if ( !vals.length ) { return; }
return ( v.fulltext || '' ).replace( /^Category:/, '' );
Β 
} )
var group = document.createElement( 'div' );
.filter( function ( v ) { return TOPICS.indexOf( v ) !== -1; } )
group.className = 'gplx-filter-group';
.join( ', ' );
Β 
return {
var label = document.createElement( 'span' );
name: getProp( p, 'Document' ) || page.fulltext,
label.className = 'gplx-filter-label';
url: page.fullurl,
label.textContent = g.label;
topic: cats,
Β 
region: getProp( p, 'Region' ),
var row = document.createElement( 'div' );
type: getProp( p, 'Type' ),
row.className = 'gplx-filter-btn-row';
scope: getProp( p, 'Scope' ),
Β 
language: getProp( p, 'Language' ),
vals.forEach( function ( v ) {
restricted: getProp( p, 'Restricted' )
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 );
} );
} );
}
}


/* ---- render ---- */
/* ---- filter logic: AND across groups, OR within group ---- */


function updateRegionDropdown( results, sel ) {
function matchesFilters( result ) {
var prev = sel.value;
return FILTER_GROUPS.every( function ( g ) {
var seen = {};
var active = Object.keys( activeFilters[ g.key ] || {} );
results.forEach( function ( r ) {
if ( !active.length ) { return true; }
r.region.split( ',' ).forEach( function ( v ) {
var fieldVal = g.key === 'type' ? result._type : ( result[ g.key ] || '' );
v = v.trim();
var parts = splitValues( fieldVal );
if ( v ) { seen[ v ] = true; }
return active.some( function ( av ) {
return parts.some( function ( p ) {
return p.toLowerCase() === av.toLowerCase();
} );
} );
} );
} );
while ( sel.options.length > 1 ) { sel.remove( 1 ); }
Object.keys( seen ).sort().forEach( function ( v ) {
var opt = new Option( v, v, false, v === prev );
sel.add( opt );
} );
} );
}
}


function applyFilters( results ) {
function applyFilters( results ) {
var region = ( document.getElementById( 'gplx-filter-region' ).value || '' ).trim().toLowerCase();
var text = ( ( document.getElementById( 'gplx-filter-text' ) || {} ).value || '' ).trim().toLowerCase();
var text = ( document.getElementById( 'gplx-filter-text' ).value || '' ).trim().toLowerCase();
return results.filter( function ( r ) {
return results.filter( function ( r ) {
if ( region && r.region.toLowerCase().indexOf( region ) === -1 ) { return false; }
if ( !matchesFilters( r ) ) { return false; }
if ( text ) {
if ( text ) {
var haystack = [ r.name, r.scope, r.type, r.language, r.topic ].join( ' ' ).toLowerCase();
var hay = [ r.displayName, r.validityArea, r.scopes, r.status,
if ( haystack.indexOf( text ) === -1 ) { return false; }
r.docType, r.language, r.description, r.preview ].join( ' ' ).toLowerCase();
if ( hay.indexOf( text ) === -1 ) { return false; }
}
}
return true;
return true;
} );
} );
}
}
/* ---- render results table ---- */


function renderTable( results ) {
function renderTable( results ) {
var container = document.getElementById( 'gplx-search-results' );
var container = document.getElementById( 'gplx-search-results' );
if ( !results.length ) {
if ( !results.length ) {
container.innerHTML = '<p class="gplx-no-results">No documents match the selected filters.</p>';
container.innerHTML = '<p class="gplx-no-results">No results match the selected filters.</p>';
return;
return;
}
}
var rows = results.map( function ( r ) {
var rows = results.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'
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
? '<div class="gplx-result-preview">' + esc( r.preview ) + '</div>' : '';
return '<tr>'
return '<tr>'
+ '<td><a href="' + esc( r.url ) + '">' + esc( r.name ) + '</a>' + restricted + '</td>'
+ '<td>' + typeBadge + '</td>'
+ '<td>' + esc( r.topic ) + '</td>'
+ '<td><a href="' + esc( r.url ) + '">' + esc( r.displayName ) + '</a>'
+ '<td>' + esc( r.scope ) + '</td>'
+ restricted + preview + '</td>'
+ '<td>' + esc( r.validityArea ) + '</td>'
+ '<td>' + esc( r.status ) + '</td>'
+ '<td>' + esc( r.language ) + '</td>'
+ '<td>' + esc( r.language ) + '</td>'
+ '<td>' + esc( r.region ) + '</td>'
+ '<td>' + esc( r.docType ) + '</td>'
+ '<td>' + esc( r.type ) + '</td>'
+ '</tr>';
+ '</tr>';
} );
} );
container.innerHTML = '<table class="wikitable sortable gplx-results-table">'
container.innerHTML = '<table class="wikitable sortable gplx-results-table">'
+ '<thead><tr><th>Document</th><th>Topic</th><th>Scope</th><th>Language</th><th>Region</th><th>Type</th></tr></thead>'
+ '<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>'
+ '<tbody>' + rows.join( '' ) + '</tbody>'
+ '</table>';
+ '</table>';
Line 194: Line 273:


function setStatus( msg, busy ) {
function setStatus( msg, busy ) {
var el = document.getElementById( 'gplx-search-status' );
var s = document.getElementById( 'gplx-search-status' );
el.textContent = msg;
if ( !s ) { return; }
el.className = 'gplx-search-status' + ( busy ? ' gplx-search-busy' : '' );
s.textContent = msg;
s.className = 'gplx-search-status' + ( busy ? ' gplx-search-busy' : '' );
}
}


Line 202: Line 282:
var visible = applyFilters( allResults );
var visible = applyFilters( allResults );
setStatus( visible.length + ' of ' + allResults.length
setStatus( visible.length + ' of ' + allResults.length
+ ' document' + ( allResults.length !== 1 ? 's' : '' ), false );
+ ' result' + ( allResults.length !== 1 ? 's' : '' ), false );
renderTable( visible );
renderTable( visible );
}
}


function load( topic, regionSel ) {
/* ---- build container UI ---- */
setStatus( 'Loading…', true );
Β 
document.getElementById( 'gplx-search-results' ).innerHTML = '';
function buildUI( container ) {
( new mw.Api() ).get( {
var filterSection = document.createElement( 'div' );
action: 'ask',
filterSection.className = 'gplx-filter-buttons';
query: buildQuery( topic ),
Β 
format: 'json'
var clearBtn = document.createElement( 'button' );
} ).done( function ( data ) {
clearBtn.type = 'button';
allResults = parseResponse( data );
clearBtn.className = 'gplx-filter-clear';
updateRegionDropdown( allResults, regionSel );
clearBtn.textContent = 'Clear all filters';
clearBtn.addEventListener( 'click', function () {
FILTER_GROUPS.forEach( function ( g ) { activeFilters[ g.key ] = {}; } );
document.querySelectorAll( '.gplx-filter-btn' ).forEach( function ( b ) {
b.classList.remove( 'is-active' );
b.setAttribute( 'aria-pressed', 'false' );
} );
refresh();
refresh();
} ).fail( function () {
setStatus( 'Failed to load results. Please reload the page.', false );
} );
} );
var searchLabel = document.createElement( 'label' );
searchLabel.className = 'gplx-filter-label';
searchLabel.htmlFor = 'gplx-filter-text';
searchLabel.textContent = 'Search in titles and text';
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 and text' );
var searchGroup = document.createElement( 'div' );
searchGroup.className = 'gplx-filter-group gplx-filter-group--grow';
searchGroup.style.marginBottom = '0.5rem';
searchGroup.appendChild( searchLabel );
searchGroup.appendChild( textInput );
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';
container.innerHTML = '';
container.appendChild( filterSection );
container.appendChild( clearBtn );
container.appendChild( searchGroup );
container.appendChild( status );
container.appendChild( results );
return { filterSection: filterSection, textInput: textInput };
}
/* ---- load data ---- */
function load( controls ) {
setStatus( 'Loading…', true );
var api = new mw.Api();
var docResults = [], refResults = [];
var docDone = false, refDone = false;
function onBothDone() {
allResults = docResults.concat( refResults );
fetchExtracts( allResults, function () {
var values = collectFieldValues( allResults );
renderFilterButtons( controls.filterSection, values );
refresh();
} );
}
api.get( { action: 'ask', query: DOC_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { docResults = parseResults( data, 'Document' ); }
docDone = true;
if ( refDone ) { onBothDone(); }
} );
api.get( { action: 'ask', query: REF_QUERY, format: 'json' } )
.always( function ( data ) {
if ( data && data.query ) { refResults = parseResults( data, 'Reference' ); }
refDone = true;
if ( docDone ) { onBothDone(); }
} );
}
}


Line 228: Line 378:
var container = document.getElementById( 'gplx-search-app' );
var container = document.getElementById( 'gplx-search-app' );
if ( !container ) { return; }
if ( !container ) { return; }
var controls = buildUI( container );
var controls = buildUI( container );
var debounce;
var debounce;
controls.topicSel.addEventListener( 'change', function () {
load( controls.topicSel.value, controls.regionSel );
} );
controls.regionSel.addEventListener( 'change', refresh );
controls.textInput.addEventListener( 'input', function () {
controls.textInput.addEventListener( 'input', function () {
clearTimeout( debounce );
clearTimeout( debounce );
debounce = setTimeout( refresh, 250 );
debounce = setTimeout( refresh, 250 );
} );
} );
Β 
load( controls );
load( '', controls.regionSel );
} );
} );
} );
} );
}() );
}() );

Revision as of 17:23, 27 June 2026

/* site-wide JS β€” VoteNY toggle is handled in Vote.js */

/* GxPlex Search β€” chainable filter buttons */
( function () {
	if ( mw.config.get( 'wgPageName' ) !== 'GxPlex_Search' ) {
		return;
	}

	/* Filter dimensions shown as button groups.
	 * Free-text fields (description, contributors, version) are excluded here
	 * but remain searchable via the text input. */
	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' },
		{ key: 'submittedBy', label: 'Submitted by' }
	];

	/* SMW property names must match the {{#set:}} calls on Document/Reference pages. */
	var DOC_QUERY = '[[Document:+]]'
		+ '|?DocValidityArea=validityArea'
		+ '|?DocScopes=scopes'
		+ '|?DocName=docName'
		+ '|?DocVersion=version'
		+ '|?DocStatus=status'
		+ '|?DocType=docType'
		+ '|?DocLanguage=language'
		+ '|?DocDescription=description'
		+ '|?DocRestricted=restricted'
		+ '|?DocSubmittedBy=submittedBy'
		+ '|limit=500'
		+ '|sort=DocName'
		+ '|order=asc';

	/* Chained properties (HasDocument.Xyz) allow filtering References
	 * by their parent document's metadata. */
	var REF_QUERY = '[[Reference:+]]'
		+ '|?HasDocument=linkedDoc'
		+ '|?HasDocument.DocValidityArea=validityArea'
		+ '|?HasDocument.DocScopes=scopes'
		+ '|?HasDocument.DocStatus=status'
		+ '|?HasDocument.DocType=docType'
		+ '|?HasDocument.DocLanguage=language'
		+ '|?HasDocument.DocRestricted=restricted'
		+ '|?HasDocument.DocSubmittedBy=submittedBy'
		+ '|?RefScope=refScope'
		+ '|?RefLanguage=refLanguage'
		+ '|?RefTagsEN=tagsEN'
		+ '|limit=500';

	var allResults = [];
	/* activeFilters: { groupKey: { value: true } } β€” plain objects, no Set dependency */
	var activeFilters = {};

	/* ---- helpers ---- */

	function esc( str ) {
		return String( str || '' )
			.replace( /&/g, '&amp;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' )
			.replace( /"/g, '&quot;' );
	}

	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 str.split( ',' ).map( function ( v ) { return v.trim(); } ).filter( Boolean );
	}

	/* ---- parse SMW responses ---- */

	function parseResults( data, type ) {
		var raw = ( data.query && data.query.results ) || {};
		return Object.keys( raw ).map( function ( key ) {
			var page = raw[ key ];
			var p = page.printouts || {};
			return {
				_type: type,
				fulltext: page.fulltext,
				url: page.fullurl,
				displayName: type === 'Document'
					? ( getProp( p, 'docName' ) || page.fulltext.replace( /^Document:/, '' ) )
					: page.fulltext.replace( /^Reference:/, '' ),
				validityArea: getProp( p, 'validityArea' ),
				scopes:       getProp( p, 'scopes' ),
				status:       getProp( p, 'status' ),
				docType:      getProp( p, 'docType' ),
				language:     getProp( p, 'language' ),
				restricted:   getProp( p, 'restricted' ),
				submittedBy:  getProp( p, 'submittedBy' ),
				description:  getProp( p, 'description' ),
				preview: ''
			};
		} );
	}

	/* ---- text extracts (250 chars, batches of 50) ---- */

	function fetchExtracts( results, callback ) {
		if ( !results.length ) { callback(); return; }

		var byTitle = {};
		results.forEach( function ( r ) { byTitle[ r.fulltext ] = r; } );

		var titles = results.map( function ( r ) { return r.fulltext; } );
		var batches = [];
		for ( var i = 0; i < titles.length; i += 50 ) {
			batches.push( titles.slice( i, i + 50 ) );
		}

		var done = 0;
		var api = new mw.Api();
		batches.forEach( function ( batch ) {
			api.get( {
				action: 'query',
				prop: 'extracts',
				exchars: 250,
				exintro: true,
				explaintext: true,
				titles: batch.join( '|' ),
				format: 'json'
			} ).always( function ( data ) {
				if ( data && data.query && data.query.pages ) {
					Object.keys( data.query.pages ).forEach( function ( id ) {
						var pg = data.query.pages[ id ];
						if ( byTitle[ pg.title ] ) {
							byTitle[ pg.title ].preview = ( pg.extract || '' ).replace( /\s+/g, ' ' ).trim().slice( 0, 250 );
						}
					} );
				}
				done++;
				if ( done === batches.length ) { callback(); }
			} );
		} );
	}

	/* ---- build filter button UI ---- */

	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;
	}

	function renderFilterButtons( filterSection, values ) {
		filterSection.innerHTML = '';
		activeFilters = {};
		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 );
		} );
	}

	/* ---- filter logic: AND across groups, OR within group ---- */

	function matchesFilters( result ) {
		return 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();
				} );
			} );
		} );
	}

	function applyFilters( results ) {
		var text = ( ( document.getElementById( 'gplx-filter-text' ) || {} ).value || '' ).trim().toLowerCase();
		return results.filter( function ( r ) {
			if ( !matchesFilters( r ) ) { return false; }
			if ( text ) {
				var hay = [ r.displayName, r.validityArea, r.scopes, r.status,
					r.docType, r.language, r.description, r.preview ].join( ' ' ).toLowerCase();
				if ( hay.indexOf( text ) === -1 ) { return false; }
			}
			return true;
		} );
	}

	/* ---- render results table ---- */

	function renderTable( results ) {
		var container = document.getElementById( 'gplx-search-results' );
		if ( !results.length ) {
			container.innerHTML = '<p class="gplx-no-results">No results match the selected filters.</p>';
			return;
		}
		var rows = results.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.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 refresh() {
		var visible = applyFilters( allResults );
		setStatus( visible.length + ' of ' + allResults.length
			+ ' result' + ( allResults.length !== 1 ? 's' : '' ), false );
		renderTable( visible );
	}

	/* ---- 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.addEventListener( 'click', function () {
			FILTER_GROUPS.forEach( function ( g ) { activeFilters[ g.key ] = {}; } );
			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 and text';

		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 and text' );

		var searchGroup = document.createElement( 'div' );
		searchGroup.className = 'gplx-filter-group gplx-filter-group--grow';
		searchGroup.style.marginBottom = '0.5rem';
		searchGroup.appendChild( searchLabel );
		searchGroup.appendChild( textInput );

		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';

		container.innerHTML = '';
		container.appendChild( filterSection );
		container.appendChild( clearBtn );
		container.appendChild( searchGroup );
		container.appendChild( status );
		container.appendChild( results );

		return { filterSection: filterSection, textInput: textInput };
	}

	/* ---- load data ---- */

	function load( controls ) {
		setStatus( 'Loading…', true );
		var api = new mw.Api();
		var docResults = [], refResults = [];
		var docDone = false, refDone = false;

		function onBothDone() {
			allResults = docResults.concat( refResults );
			fetchExtracts( allResults, function () {
				var values = collectFieldValues( allResults );
				renderFilterButtons( controls.filterSection, values );
				refresh();
			} );
		}

		api.get( { action: 'ask', query: DOC_QUERY, format: 'json' } )
			.always( function ( data ) {
				if ( data && data.query ) { docResults = parseResults( data, 'Document' ); }
				docDone = true;
				if ( refDone ) { onBothDone(); }
			} );

		api.get( { action: 'ask', query: REF_QUERY, format: 'json' } )
			.always( function ( data ) {
				if ( data && data.query ) { refResults = parseResults( data, 'Reference' ); }
				refDone = true;
				if ( docDone ) { onBothDone(); }
			} );
	}

	/* ---- init ---- */

	mw.loader.using( [ 'mediawiki.api' ], function () {
		$( function () {
			var container = document.getElementById( 'gplx-search-app' );
			if ( !container ) { return; }
			var controls = buildUI( container );
			var debounce;
			controls.textInput.addEventListener( 'input', function () {
				clearTimeout( debounce );
				debounce = setTimeout( refresh, 250 );
			} );
			load( controls );
		} );
	} );
}() );