mediawiki-skins-Citizen/resources/skins.citizen.search/searchClients/mwActionApi.js
alistair3149 a9ff2da26d
feat(search): use 220px thumbnail for Action API search
220px is standardized to avoid further thumbnail fragmentation.
It should not affect the REST API search.
T322621
2024-10-30 02:01:18 -04:00

217 lines
5.6 KiB
JavaScript

/** @module mwActionApiSearchClient */
const fetchJson = require( '../fetch.js' );
const urlGenerator = require( '../urlGenerator.js' );
// Based on mediawiki.searchSuggest
// eslint-disable-next-line array-callback-return
const searchNS = Object.entries( mw.config.get( 'wgFormattedNamespaces' ) ).map( ( [ nsID ] ) => {
if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
// Cast string key to number
return Number( nsID );
}
} ).filter( ( item ) => item !== undefined ).join( '|' );
/**
* @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, 100 );
}
};
// Early exit with there are no query
if ( !response.query ) {
return { query, results: {} };
}
response = response.query;
// Merge redirects array into pages array if avaliable
// So the from key can be used for matched title
if ( response.redirects ) {
const pageCount = response.pages.length;
response.pages = Object.values(
[ ...response.redirects, ...response.pages ].reduce( ( acc, curr ) => {
const index = curr.index;
acc[ index ] = { ...acc[ index ], ...curr };
return acc;
}, [] )
);
// Sometimes there can be multiple redirect object for the same page, only take the one with lower index
if ( response.pages.length !== pageCount ) {
response.pages = response.pages.filter( ( obj ) => Object.prototype.hasOwnProperty.call( obj, 'title' ) );
}
}
// 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 || page.title,
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',
gpssearch: q,
gpsnamespace: searchNS,
gpslimit: limit.toString(),
redirects: '',
prop: 'pageprops|pageimages',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pilicense: 'any',
pithumbsize: 220,
pilimit: limit.toString()
};
switch ( descriptionSource ) {
case 'wikidata':
params.prop += '|description';
params.descprefersource = 'local';
break;
case 'textextracts':
params.prop += '|extracts';
params.exchars = '100';
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 ) => adaptApiResponse( config, q, res, showDescription ) );
return {
abort: result.abort,
fetch: searchResponsePromise
};
}
};
}
module.exports = mwActionApiSearchClient;