const TemplateMenuItem = require( './TemplateMenuItem.js' );
const templateDiscoveryConfig = require( './config.json' );
const FavoritesStore = require( './FavoritesStore.js' );
const mwConfig = require( './mwConfig.json' );
/**
* @class
* @extends OO.ui.ComboBoxInputWidget
*
* @constructor
* @param {Object} [config] Configuration options.
* @param {number} [config.limit=10]
* @param {mw.Api} [config.api] Optional MediaWiki API, for testing
* @param {FavoritesStore} config.favoritesStore Data store
*/
function SearchWidget( config ) {
const placeholder = templateDiscoveryConfig.cirrusSearchLoaded ?
OO.ui.deferMsg( 'templatedata-search-description-cirrussearch' ) :
OO.ui.deferMsg( 'templatedata-search-description' );
config = Object.assign( {
placeholder: placeholder,
icon: 'search'
}, config );
SearchWidget.super.call( this, config );
OO.ui.mixin.LookupElement.call( this );
this.limit = config.limit || 10;
this.api = config.api || new mw.Api();
this.favoritesStore = config.favoritesStore;
}
/* Setup */
OO.inheritClass( SearchWidget, OO.ui.ComboBoxInputWidget );
OO.mixinClass( SearchWidget, OO.ui.mixin.LookupElement );
/* Events */
/**
* When a template is choosen from the menu.
*
* @event choose
* @param {Object} The template data of the chosen template.
*/
/**
* When the current value of the search input matches a search result (regardless of whether that
* result is highlighted).
*
* @event match
* @param {Object} templateData Template data of the matched search result.
*/
/* Methods */
/**
* This helper method is modeled after mw.widgets.TitleWidget, even if this is *not* a TitleWidget.
*
* @private
* @method
* @param {string} query What the user typed
* @return {Object} Parameters for the MediaWiki action API
*/
SearchWidget.prototype.getApiParams = function ( query ) {
const params = {
action: 'templatedata',
includeMissingTitles: 1,
lang: mw.config.get( 'wgUserLanguage' ),
generator: 'prefixsearch',
gpssearch: query,
gpsnamespace: mw.config.get( 'wgNamespaceIds' ).template,
gpslimit: this.limit,
redirects: 1
};
if ( templateDiscoveryConfig.cirrusSearchLoaded ) {
Object.assign( params, {
generator: 'search',
gsrsearch: params.gpssearch,
gsrnamespace: params.gpsnamespace,
gsrlimit: params.gpslimit,
gsrprop: [ 'redirecttitle' ]
} );
// Adding the asterisk to emulate a prefix search behavior. It does not make sense in all
// cases though. We're limiting it to be add only of the term ends with a letter or numeric
// character.
// eslint-disable-next-line es-x/no-regexp-unicode-property-escapes, prefer-regex-literals
const endsWithAlpha = new RegExp( '[\\p{L}\\p{N}]$', 'u' );
if ( endsWithAlpha.test( params.gsrsearch ) ) {
params.gsrsearch += '*';
}
delete params.gpssearch;
delete params.gpsnamespace;
delete params.gpslimit;
}
return params;
};
/**
* Get a new request object of the current lookup query value.
*
* @protected
* @method
* @return {jQuery.Promise} jQuery AJAX object, or promise object with an .abort() method
*/
SearchWidget.prototype.getLookupRequest = function () {
const query = this.getValue(),
params = this.getApiParams( query );
let promise = this.api.get( params );
// No point in running prefix search a second time
if ( params.generator !== 'prefixsearch' ) {
promise = promise
.then( this.addExactMatch.bind( this ) )
.promise( { abort: function () {} } );
}
return promise;
};
/**
* @private
* @method
* @param {Object} response Action API response from server
* @return {Object} Modified response
*/
SearchWidget.prototype.addExactMatch = function ( response ) {
const query = this.getValue(),
lowerQuery = query.trim().toLowerCase();
if ( !response.pages || !lowerQuery ) {
return response;
}
const containsExactMatch = Object.keys( response.pages ).some( ( pageId ) => {
const page = response.pages[ pageId ],
title = mw.Title.newFromText( page.title );
return title.getMainText().toLowerCase() === lowerQuery;
} );
if ( containsExactMatch ) {
return response;
}
const limit = this.limit;
return this.api.get( {
action: 'templatedata',
includeMissingTitles: 1,
redirects: 1,
lang: mw.config.get( 'wgUserLanguage' ),
// Can't use a direct lookup by title because we need this to be case-insensitive
generator: 'prefixsearch',
gpssearch: query,
gpsnamespace: mw.config.get( 'wgNamespaceIds' ).template,
// Try to fill with prefix matches, otherwise just the top-1 prefix match
gpslimit: limit
} ).then( ( prefixMatches ) => {
// action=templatedata returns page objects in `{ pages: {} }`, keyed by page id
// Copy keys because the loop below needs an ordered array, not an object
for ( const pageId in prefixMatches.pages ) {
prefixMatches.pages[ pageId ].pageid = pageId;
}
// Make sure the loop below processes the results by relevance
const pages = OO.getObjectValues( prefixMatches.pages )
.sort( ( a, b ) => a.index - b.index );
for ( const i in pages ) {
const prefixMatch = pages[ i ];
if ( !( prefixMatch.pageid in response.pages ) ) {
// Move prefix matches to the top, indexed from -9 to 0, relevant for e.g. {{!!}}
// Note: Sorting happens down in getLookupCacheDataFromResponse()
prefixMatch.index -= limit;
response.pages[ prefixMatch.pageid ] = prefixMatch;
}
// Check only after the top-1 prefix match is guaranteed to be present
// Note: Might be 11 at this point, truncated in getLookupCacheDataFromResponse()
if ( Object.keys( response.pages ).length >= limit ) {
break;
}
}
return response;
},
// Proceed with the unmodified response in case the additional API request failed
() => response
)
.promise( { abort: function () {} } );
};
/**
* Pre-process data returned by the request from {@see getLookupRequest}.
*
* The return value of this function will be cached, and any further queries for the given value
* will use the cache rather than doing API requests.
*
* @protected
* @method
* @param {Object} response Response from server
* @return {Object[]} Config for {@see TemplateMenuItem} widgets
*/
SearchWidget.prototype.getLookupCacheDataFromResponse = function ( response ) {
const templateData = response.pages;
// Prepare the separate "redirects" structure to be converted to the CirrusSearch
// "redirecttitle" field
const redirectedFrom = {};
if ( response.redirects ) {
response.redirects.forEach( ( redirect ) => {
redirectedFrom[ redirect.to ] = redirect.from;
} );
}
const searchResults = Object.keys( templateData ).map( ( pageId ) => {
const page = templateData[ pageId ];
page.pageId = pageId;
if ( !page.redirecttitle && page.title in redirectedFrom ) {
page.redirecttitle = redirectedFrom[ page.title ];
}
const title = mw.Title.newFromText( page.title );
// Skip non-TemplateDataEditorNamespaces namespaces
if ( !mwConfig.TemplateDataEditorNamespaces.includes( title.getNamespaceId() ) ) {
return null;
}
// Skip common subpages
const subpages = mw.msg( 'templatedata-excluded-subpages' )
.split( '|' )
.map( ( e ) => e.trim() )
.filter( Boolean );
if ( subpages.some( ( s ) => title.getMainText().endsWith( '/' + s ) ) ) {
return null;
}
/**
* Config for the {@see TemplateMenuItem} widget:
* - data: {@see OO.ui.Element} and getData()
* - label: {@see OO.ui.mixin.LabelElement} and getLabel()
* - description: {@see TemplateMenuItem}
*/
return {
data: page,
label: title.getRelativeText( mw.config.get( 'wgNamespaceIds' ).template ),
description: page.description
};
// Filter map results to remove null values
} ).filter( ( result ) => result !== null );
const lowerQuery = this.getValue().trim().toLowerCase();
searchResults.sort( ( a, b ) => {
// Force exact matches to be at the top
if ( a.label.toLowerCase() === lowerQuery ) {
return -1;
} else if ( b.label.toLowerCase() === lowerQuery ) {
return 1;
}
// Restore original (prefix)search order, possibly messed up because of the generator
if ( 'index' in a.data && 'index' in b.data ) {
return a.data.index - b.data.index;
}
return 0;
} );
// Might be to many results because of the additional exact match search above
if ( searchResults.length > this.limit ) {
searchResults.splice( this.limit );
}
return searchResults;
};
/**
* Get a list of menu option widgets from the (possibly cached) data returned by
* {@see getLookupCacheDataFromResponse}.
*
* @protected
* @method
* @param {Object[]} data Search results from {@see getLookupCacheDataFromResponse}
* @return {OO.ui.MenuOptionWidget[]}
*/
SearchWidget.prototype.getLookupMenuOptionsFromData = function ( data ) {
return data.map( ( config ) => {
// See if this template matches, and if it does then emit an event.
const valueAsTitle = new mw.Title( this.getValue() );
if ( valueAsTitle.getMainText() === config.label ) {
this.emit( 'match', config.data );
}
return new TemplateMenuItem( config, this.favoritesStore );
} );
};
/**
* Handle menu item 'choose' event, updating the text input value to the value of the clicked item.
*
* @protected
* @fires choose
* @param {OO.ui.MenuOptionWidget} item Selected item
*/
SearchWidget.prototype.onLookupMenuChoose = function ( item ) {
this.setValue( item.getLabel() );
this.emit( 'choose', item.getData() );
};
module.exports = SearchWidget;