refactor(search): ♻️ reimplement and clean up search clients

This should make the search clients more readable and easier to expand
这个提交包含在:
alistair3149 2023-08-02 18:56:08 -04:00
父节点 c2431ebeba
当前提交 431599cb59
找不到此签名对应的密钥
共有 13 个文件被更改,包括 461 次插入281 次删除

查看文件

@ -19,6 +19,7 @@
"es-x/no-array-prototype-includes": "warn",
"es-x/no-optional-chaining": "warn",
"es-x/no-nullish-coalescing-operators": "warn",
"es-x/no-rest-spread-properties": "warn",
"es-x/no-symbol-prototype-description": "warn",
"compat/compat": "warn",
"mediawiki/class-doc": "off"

查看文件

@ -18,6 +18,7 @@
"devDependencies": {
"@commitlint/cli": "^17.6.7",
"@commitlint/config-conventional": "^17.6.7",
"@wikimedia/types-wikimedia": "0.4.1",
"devmoji": "^2.3.0",
"eslint-config-wikimedia": "0.25.1",
"grunt-banana-checker": "0.11.0",

查看文件

@ -1,89 +0,0 @@
/**
* @typedef {Object} Results
* @property {string} id The page ID of the page
* @property {string} title The title of the page.
* @property {string} desc The description of the page.
* @property {string} thumbnail The url of the thumbnail of the page.
*
* @global
*/
const fetchJson = require( '../fetch.js' );
const defaultGatewayName = require( '../config.json' ).wgCitizenSearchGateway;
/**
* Setup the gateway based on the name provided
*
* @param {string} gatewayName
* @return {module}
*/
function getGateway( gatewayName ) {
switch ( gatewayName ) {
case 'mwActionApi':
return require( './mwActionApi.js' );
case 'mwRestApi':
return require( './mwRestApi.js' );
case 'smwAskApi':
return require( './smwAskApi.js' );
default:
throw new Error( 'Unknown search gateway' );
}
}
/**
* Fetch suggestion from gateway and return the results object
*
* @param {string} searchQuery
* @return {Object} Results
*/
function getResults( searchQuery ) {
let gateway = getGateway( defaultGatewayName );
/*
* Multi-gateway search experiment
* This is a rough proof of concept for allowing multiple search gateway
* We are using SMW Ask as an experiment
*
* TODO:
* - Clean up gateway into JSON data perhaps
* - Implement UI support (initial states, search syntax suggestions)
* - Search query needs to be trimmed earlier so that query in the UI does not show the command
*/
const gatewayMap = new Map( [
[ 'action', 'mwActionApi' ],
[ 'ask', 'smwAskApi' ],
[ 'rest', 'mwRestApi' ]
] );
for ( const [ command, gatewayName ] of gatewayMap ) {
if ( searchQuery.startsWith( `/${command}` ) ) {
gateway = getGateway( gatewayName );
/* Remove command (e.g. /smw) from query */
searchQuery = searchQuery.slice( command.length + 1 );
break;
}
}
/* Abort early if there are no search query */
if ( searchQuery.length === 0 ) {
return {};
}
const result = fetchJson( gateway.getUrl( searchQuery ), {
headers: {
accept: 'application/json'
}
} );
const searchResponsePromise = result.fetch
.then( ( /** @type {RestResponse} */ res ) => {
return gateway.convertDataToResults( res );
} );
return {
abort: result.abort,
fetch: searchResponsePromise
};
}
module.exports = {
getResults: getResults
};

查看文件

@ -1,119 +0,0 @@
const config = require( '../config.json' ),
descriptionSource = config.wgCitizenSearchDescriptionSource;
/**
* Build URL used for fetch request
*
* @param {string} input
* @return {string} url
*/
function getUrl( input ) {
const endpoint = config.wgScriptPath + '/api.php?format=json',
cacheExpiry = config.wgSearchSuggestCacheExpiry,
maxResults = config.wgCitizenMaxSearchResults,
query = {
action: 'query',
smaxage: cacheExpiry,
maxage: cacheExpiry,
generator: 'prefixsearch',
prop: 'pageprops|pageimages',
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: 200,
pilimit: maxResults,
gpssearch: input,
gpsnamespace: 0,
gpslimit: maxResults
};
switch ( descriptionSource ) {
case 'wikidata':
query.prop += '|description';
break;
case 'textextracts':
query.prop += '|extracts';
query.exchars = '60';
query.exintro = '1';
query.exlimit = maxResults;
query.explaintext = '1';
break;
case 'pagedescription':
query.prop += '|pageprops';
query.ppprop = 'description';
break;
}
let queryString = '';
for ( const property in query ) {
queryString += '&' + property + '=' + query[ property ];
}
return endpoint + queryString;
}
/**
* Map raw response to Results object
*
* @param {Object} data
* @return {Object} Results
*/
function convertDataToResults( data ) {
const getDisplayTitle = ( item ) => {
if ( item.pageprops && item.pageprops.displaytitle ) {
return item.pageprops.displaytitle;
} else {
return item.title;
}
};
const getDescription = ( item ) => {
switch ( descriptionSource ) {
case 'wikidata':
/* eslint-disable-next-line es-x/no-symbol-prototype-description */
return item.description || '';
case 'textextracts':
return item.extract || '';
case 'pagedescription':
/* eslint-disable es-x/no-symbol-prototype-description */
if ( item.pageprops && item.pageprops.description ) {
return item.pageprops.description.slice( 0, 60 ) + '...';
/* eslint-enable es-x/no-symbol-prototype-description */
} else {
return '';
}
}
};
const results = [];
if ( typeof ( ( data || [] ).query || [] ).pages === 'undefined' ) {
return [];
}
data = Object.values( data.query.pages );
// Sort the data with the index property since it is not in order
data.sort( ( a, b ) => {
return a.index - b.index;
} );
for ( let i = 0; i < data.length; i++ ) {
results[ i ] = {
id: data[ i ].pageid,
key: data[ i ].title,
title: getDisplayTitle( data[ i ] ),
desc: getDescription( data[ i ] )
};
if ( data[ i ].thumbnail && data[ i ].thumbnail.source ) {
results[ i ].thumbnail = data[ i ].thumbnail.source;
}
}
return results;
}
module.exports = {
getUrl: getUrl,
convertDataToResults: convertDataToResults
};

查看文件

@ -1,52 +0,0 @@
const config = require( '../config.json' );
/**
* Build URL used for fetch request
*
* @param {string} input
* @return {string} url
*/
function getUrl( input ) {
const endpoint = config.wgScriptPath + '/rest.php/v1/search/title?q=',
query = '&limit=' + config.wgCitizenMaxSearchResults;
return endpoint + input + query;
}
/**
* Map raw response to Results object
*
* @param {Object} data
* @return {Object} Results
*/
function convertDataToResults( data ) {
const results = [];
data = ( data || [] ).pages || [];
for ( let i = 0; i < data.length; i++ ) {
results[ i ] = {
id: data[ i ].id,
key: data[ i ].key,
title: data[ i ].title,
/* eslint-disable-next-line es-x/no-symbol-prototype-description */
desc: data[ i ].description
};
// Redirect title
// Since 1.38
if ( data[ i ].matched_title ) {
results[ i ].matchedTitle = data[ i ].matched_title;
}
if ( data[ i ].thumbnail && data[ i ].thumbnail.url ) {
results[ i ].thumbnail = data[ i ].thumbnail.url;
}
}
return results;
}
module.exports = {
getUrl: getUrl,
convertDataToResults: convertDataToResults
};

查看文件

@ -0,0 +1,195 @@
/** @module mwActionApiSearchClient */
const fetchJson = require( '../fetch.js' );
const urlGenerator = require( '../urlGenerator.js' );
/**
* @typedef {Object} ActionResponse
* @property {ActionQuery[]} query
*/
/**
* @typedef {Object} ActionQuery
* @property {ActionRedirects[] | null} redirects
* @property {ActionResult[]} pages
*/
/**
* @typedef {Object} ActionRedirects
* @property {string} from
*/
/**
* @typedef {Object} ActionResult
* @property {number} pageid
* @property {number} index
* @property {string} title
* @property {ActionThumbnail | null} [thumbnail]
*/
/**
* @typedef {Object} ActionThumbnail
* @property {string} source
* @property {number | null} [width]
* @property {number | null} [height]
*/
/**
* @typedef {Object} SearchResponse
* @property {string} query
* @property {SearchResult[]} results
*/
/**
* @param {MwMap} config
* @param {string} query
* @param {Object} response
* @param {boolean} showDescription
* @return {SearchResponse}
*/
function adaptApiResponse( config, query, response, showDescription ) {
const urlGeneratorInstance = urlGenerator( config );
const getDescription = ( page ) => {
switch ( config.wgCitizenSearchDescriptionSource ) {
case 'wikidata':
return page?.description;
case 'textextracts':
return page?.extract;
case 'pagedescription':
return page?.pageprops?.description?.slice( 0, 60 );
}
};
response = response.query;
// Merge redirects array into pages array if avaliable
// So the from key can be used for matched title
if ( response.redirects ) {
response.pages = Object.values(
[ ...response.redirects, ...response.pages ].reduce( ( acc, curr ) => {
const index = curr.index;
acc[ index ] = { ...acc[ index ], ...curr };
return acc;
}, [] )
);
}
// Sort pages by index key instead of page id
response.pages.sort( ( a, b ) => a.index - b.index );
return {
query,
results: response.pages.map( ( page ) => {
const thumbnail = page.thumbnail;
return {
id: page.pageid,
label: page.from,
key: page.title.replace( / /g, '_' ),
title: page.title,
description: showDescription ? getDescription( page ) : undefined,
url: urlGeneratorInstance.generateUrl( page ),
thumbnail: thumbnail ? {
url: thumbnail.source,
width: thumbnail.width ?? undefined,
height: thumbnail.height ?? undefined
} : undefined
};
} )
};
}
/**
* @typedef {Object} AbortableSearchFetch
* @property {Promise<SearchResponse>} fetch
* @property {Function} abort
*/
/**
* @callback fetchByTitle
* @param {string} query The search term.
* @param {number} [limit] Maximum number of results.
* @return {AbortableSearchFetch}
*/
/**
* @callback loadMore
* @param {string} query The search term.
* @param {number} offset The number of search results that were already loaded.
* @param {number} [limit] How many further search results to load (at most).
* @return {AbortableSearchFetch}
*/
/**
* @typedef {Object} SearchClient
* @property {fetchByTitle} fetchByTitle
* @property {loadMore} [loadMore]
*/
/**
* @param {MwMap} config
* @return {SearchClient}
*/
function mwActionApiSearchClient( config ) {
return {
/**
* @type {fetchByTitle}
*/
fetchByTitle: ( q, limit = config.wgCitizenMaxSearchResults, showDescription = true ) => {
const cacheExpiry = config.wgSearchSuggestCacheExpiry;
const descriptionSource = config.wgCitizenSearchDescriptionSource;
const searchApiUrl = config.wgScriptPath + '/api.php';
const params = {
format: 'json',
formatversion: '2',
action: 'query',
smaxage: cacheExpiry,
maxage: cacheExpiry,
generator: 'prefixsearch',
prop: 'pageprops|pageimages',
redirects: '',
ppprop: 'displaytitle',
pilicense: 'any',
piprop: 'thumbnail',
pithumbsize: 200,
pilimit: limit.toString(),
gpssearch: q,
gpslimit: limit.toString()
};
switch ( descriptionSource ) {
case 'wikidata':
params.prop += '|description';
params.descprefersource = 'local';
break;
case 'textextracts':
params.prop += '|extracts';
params.exchars = '60';
params.exintro = '1';
params.exlimit = limit.toString();
params.explaintext = '1';
break;
case 'pagedescription':
params.prop += '|pageprops';
params.ppprop = 'description';
break;
}
const search = new URLSearchParams( params );
const url = `${searchApiUrl}?${search.toString()}`;
const result = fetchJson( url, {
headers: {
accept: 'application/json'
}
} );
const searchResponsePromise = result.fetch
.then( ( /** @type {ActionResponse} */ res ) => {
return adaptApiResponse( config, q, res, showDescription );
} );
return {
abort: result.abort,
fetch: searchResponsePromise
};
}
};
}
module.exports = mwActionApiSearchClient;

查看文件

@ -0,0 +1,122 @@
/** @module mwRestApiSearchClient */
const fetchJson = require( '../fetch.js' );
const urlGenerator = require( '../urlGenerator.js' );
/**
* @typedef {Object} RestResponse
* @property {RestResult[]} pages
*/
/**
* @typedef {Object} RestResult
* @property {number} id
* @property {string} key
* @property {string} title
* @property {string | null } matched_title
* @property {string} [description]
* @property {RestThumbnail | null} [thumbnail]
*/
/**
* @typedef {Object} RestThumbnail
* @property {string} url
* @property {number | null} [width]
* @property {number | null} [height]
*/
/**
* @typedef {Object} SearchResponse
* @property {string} query
* @property {SearchResult[]} results
*/
/**
* @param {MwMap} config
* @param {string} query
* @param {Object} response
* @param {boolean} showDescription
* @return {SearchResponse}
*/
function adaptApiResponse( config, query, response, showDescription ) {
const urlGeneratorInstance = urlGenerator( config );
return {
query,
results: response.pages.map( ( page ) => {
const thumbnail = page.thumbnail;
return {
id: page.id,
label: page.matched_title,
key: page.key,
title: page.title,
description: showDescription ? page.description : undefined,
url: urlGeneratorInstance.generateUrl( page ),
thumbnail: thumbnail ? {
url: thumbnail.url,
width: thumbnail.width ?? undefined,
height: thumbnail.height ?? undefined
} : undefined
};
} )
};
}
/**
* @typedef {Object} AbortableSearchFetch
* @property {Promise<SearchResponse>} fetch
* @property {Function} abort
*/
/**
* @callback fetchByTitle
* @param {string} query The search term.
* @param {number} [limit] Maximum number of results.
* @return {AbortableSearchFetch}
*/
/**
* @callback loadMore
* @param {string} query The search term.
* @param {number} offset The number of search results that were already loaded.
* @param {number} [limit] How many further search results to load (at most).
* @return {AbortableSearchFetch}
*/
/**
* @typedef {Object} SearchClient
* @property {fetchByTitle} fetchByTitle
* @property {loadMore} [loadMore]
*/
/**
* @param {MwMap} config
* @return {SearchClient}
*/
function mwRestApiSearchClient( config ) {
return {
/**
* @type {fetchByTitle}
*/
fetchByTitle: ( q, limit = config.wgCitizenMaxSearchResults, showDescription = true ) => {
const searchApiUrl = config.wgScriptPath + '/rest.php';
const params = { q, limit: limit.toString() };
const search = new URLSearchParams( params );
const url = `${searchApiUrl}/v1/search/title?${search.toString()}`;
const result = fetchJson( url, {
headers: {
accept: 'application/json'
}
} );
const searchResponsePromise = result.fetch
.then( ( /** @type {RestResponse} */ res ) => {
return adaptApiResponse( config, q, res, showDescription );
} );
return {
abort: result.abort,
fetch: searchResponsePromise
};
}
};
}
module.exports = mwRestApiSearchClient;

查看文件

@ -0,0 +1,17 @@
[
{
"id": "mwActionApi",
"name": "MediaWiki search",
"command": "action"
},
{
"id": "mwRestApi",
"name": "MediaWiki search",
"command": "rest"
},
{
"id": "smwAskApi",
"name": "Semantic search",
"command": "ask"
}
]

查看文件

@ -1,3 +1,6 @@
/*
* TODO: This is going to be refactored soon as it is using the old standard
*/
const config = require( '../config.json' );
/**

查看文件

@ -5,11 +5,27 @@ const
ACTIVE_CLASS = `${ITEM_CLASS}--active`,
HIDDEN_CLASS = `${ITEM_CLASS}--hidden`;
/**
* Config object from getCitizenSearchResourceLoaderConfig()
*/
// Config object from getCitizenSearchResourceLoaderConfig()
const config = require( './config.json' );
const gateway = require( './gateway/gateway.js' );
// Search clients definition
const searchClients = require( './searchClients/searchClients.json' );
const searchClient = {
active: null,
getData: function ( id ) {
const data = Object.values( searchClients ).find( ( item ) => item.id === id );
return data;
},
setActive: function ( id ) {
const data = this.getData( id );
if ( data ) {
const client = require( `./searchClients/${data.id}.js` );
this.active = data;
this.active.client = client( config );
}
}
};
const activeIndex = {
index: -1,
@ -165,9 +181,7 @@ function clearSuggestions() {
function getSuggestions( searchQuery, htmlSafeSearchQuery, placeholder ) {
const renderSuggestions = ( results ) => {
if ( results.length > 0 ) {
const
fragment = document.createDocumentFragment(),
suggestionLinkPrefix = `${config.wgScriptPath}/index.php?title=Special:Search&search=`;
const fragment = document.createDocumentFragment();
/**
* Return the redirect title with search query highlight
*
@ -225,16 +239,15 @@ function getSuggestions( searchQuery, htmlSafeSearchQuery, placeholder ) {
id: `${PREFIX}-suggestion-${index}`,
type: 'page',
size: 'md',
link: suggestionLinkPrefix + encodeURIComponent( result.key ),
link: result.url,
title: highlightTitle( result.title ),
// Just to be safe, not sure if the default API is HTML escaped
desc: result.desc
desc: result.description
};
if ( result.matchedTitle ) {
data.label = getRedirectLabel( result.title, result.matchedTitle );
if ( result.label ) {
data.label = getRedirectLabel( result.title, result.label );
}
if ( result.thumbnail ) {
data.thumbnail = result.thumbnail;
data.thumbnail = result.thumbnail.url;
} else {
// Thumbnail placeholder icon
data.icon = 'image';
@ -263,17 +276,17 @@ function getSuggestions( searchQuery, htmlSafeSearchQuery, placeholder ) {
// Add loading animation
searchInput.parentNode.classList.add( SEARCH_LOADING_CLASS );
const { abort, fetch } = gateway.getResults( searchQuery );
const { abort, fetch } = searchClient.active.client.fetchByTitle( searchQuery );
// Abort fetch if the input is detected
// So that fetch request won't be queued up
searchInput.addEventListener( 'input', abort, { once: true } );
fetch?.then( ( results ) => {
fetch?.then( ( response ) => {
searchInput.removeEventListener( 'input', abort );
clearSuggestions();
if ( results !== null ) {
renderSuggestions( results );
if ( response.results !== null ) {
renderSuggestions( response.results );
updateActiveIndex();
}
} ).catch( ( error ) => {
@ -498,6 +511,9 @@ function initTypeahead( searchForm, input ) {
// Init the value in case of undef error
updateActiveIndex();
// Set default active search client
searchClient.setActive( config.wgCitizenSearchGateway );
// Since searchInput is focused before the event listener is set up
onFocus();
searchInput.addEventListener( 'focus', onFocus );

查看文件

@ -0,0 +1,17 @@
/**
* @typedef {Object} SearchResult
* @property {number} id
* @property {string} key
* @property {string} title
* @property {string} [description]
* @property {SearchResultThumbnail} [thumbnail]
*/
/**
* @typedef {Object} SearchResultThumbnail
* @property {string} url
* @property {number} [width]
* @property {number} [height]
*/
/* exported SearchResult */

查看文件

@ -0,0 +1,67 @@
/*
* Adapted from Vector
* All credits go to the developers behind Vector
* @see https://github.com/wikimedia/mediawiki-skins-Vector/blob/master/resources/skins.vector.search/urlGenerator.js
*/
/**
* @typedef {Record<string,string>} UrlParams
* @param {string} title
* @param {string} fulltext
*/
/**
* @callback generateUrl
* @param {SearchResult|string} searchResult
* @param {UrlParams} [params]
* @param {string} [articlePath]
* @return {string}
*/
/**
* @typedef {Object} UrlGenerator
* @property {generateUrl} generateUrl
*/
/**
* Generates URLs for suggestions like those in MediaWiki's mediawiki.searchSuggest implementation.
*
* @param {MwMap} config
* @return {UrlGenerator}
*/
function urlGenerator( config ) {
return {
/**
* @param {SearchResult|string} suggestion
* @param {UrlParams} params
* @param {string} articlePath
* @return {string}
*/
generateUrl(
suggestion,
params = {
title: 'Special:Search'
},
articlePath = config.wgScript
) {
if ( typeof suggestion !== 'string' ) {
suggestion = suggestion.title;
} else {
// Add `fulltext` query param to search within pages and for navigation
// to the search results page (prevents being redirected to a certain
// article).
params = Object.assign( {}, params, {
fulltext: '1'
} );
}
const searchParams = new URLSearchParams(
Object.assign( {}, params, { search: suggestion } )
);
return `${articlePath}?${searchParams.toString()}`;
}
};
}
/** @module urlGenerator */
module.exports = urlGenerator;

查看文件

@ -143,6 +143,7 @@
]
},
"skins.citizen.search": {
"es6": true,
"templates": [
"resources/skins.citizen.search/templates/typeahead.mustache"
],
@ -157,10 +158,10 @@
},
"resources/skins.citizen.search/typeahead.js",
"resources/skins.citizen.search/fetch.js",
"resources/skins.citizen.search/gateway/gateway.js",
"resources/skins.citizen.search/gateway/mwActionApi.js",
"resources/skins.citizen.search/gateway/mwRestApi.js",
"resources/skins.citizen.search/gateway/smwAskApi.js"
"resources/skins.citizen.search/urlGenerator.js",
"resources/skins.citizen.search/searchClients/searchClients.json",
"resources/skins.citizen.search/searchClients/mwActionApi.js",
"resources/skins.citizen.search/searchClients/mwRestApi.js"
],
"messages": [
"citizen-search-fulltext",