diff --git a/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js deleted file mode 100644 index 3bba760b20c459..00000000000000 --- a/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.js +++ /dev/null @@ -1,237 +0,0 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; -import { decodeEntities } from '@wordpress/html-entities'; -import { __ } from '@wordpress/i18n'; - -/** - * Filters the search by type - * - * @typedef { 'attachment' | 'post' | 'term' | 'post-format' } WPLinkSearchType - */ - -/** - * A link with an id may be of kind post-type or taxonomy - * - * @typedef { 'post-type' | 'taxonomy' } WPKind - */ - -/** - * @typedef WPLinkSearchOptions - * - * @property {boolean} [isInitialSuggestions] Displays initial search suggestions, when true. - * @property {WPLinkSearchType} [type] Filters by search type. - * @property {string} [subtype] Slug of the post-type or taxonomy. - * @property {number} [page] Which page of results to return. - * @property {number} [perPage] Search results per page. - */ - -/** - * @typedef WPLinkSearchResult - * - * @property {number} id Post or term id. - * @property {string} url Link url. - * @property {string} title Title of the link. - * @property {string} type The taxonomy or post type slug or type URL. - * @property {WPKind} [kind] Link kind of post-type or taxonomy - */ - -/** - * @typedef WPLinkSearchResultAugments - * - * @property {{kind: WPKind}} [meta] Contains kind information. - * @property {WPKind} [subtype] Optional subtype if it exists. - */ - -/** - * @typedef {WPLinkSearchResult & WPLinkSearchResultAugments} WPLinkSearchResultAugmented - */ - -/** - * @typedef WPEditorSettings - * - * @property {boolean} [ disablePostFormats ] Disables post formats, when true. - */ - -/** - * Fetches link suggestions from the API. - * - * @async - * @param {string} search - * @param {WPLinkSearchOptions} [searchOptions] - * @param {WPEditorSettings} [settings] - * - * @example - * ```js - * import { __experimentalFetchLinkSuggestions as fetchLinkSuggestions } from '@wordpress/core-data'; - * - * //... - * - * export function initialize( id, settings ) { - * - * settings.__experimentalFetchLinkSuggestions = ( - * search, - * searchOptions - * ) => fetchLinkSuggestions( search, searchOptions, settings ); - * ``` - * @return {Promise< WPLinkSearchResult[] >} List of search suggestions - */ -const fetchLinkSuggestions = async ( - search, - searchOptions = {}, - settings = {} -) => { - const { - isInitialSuggestions = false, - initialSuggestionsSearchOptions = undefined, - } = searchOptions; - - const { disablePostFormats = false } = settings; - - let { - type = undefined, - subtype = undefined, - page = undefined, - perPage = isInitialSuggestions ? 3 : 20, - } = searchOptions; - - /** @type {Promise[]} */ - const queries = []; - - if ( isInitialSuggestions && initialSuggestionsSearchOptions ) { - type = initialSuggestionsSearchOptions.type || type; - subtype = initialSuggestionsSearchOptions.subtype || subtype; - page = initialSuggestionsSearchOptions.page || page; - perPage = initialSuggestionsSearchOptions.perPage || perPage; - } - - if ( ! type || type === 'post' ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'post', - subtype, - } ), - } ) - .then( ( results ) => { - return results.map( ( result ) => { - return { - ...result, - meta: { kind: 'post-type', subtype }, - }; - } ); - } ) - .catch( () => [] ) // Fail by returning no results. - ); - } - - if ( ! type || type === 'term' ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'term', - subtype, - } ), - } ) - .then( ( results ) => { - return results.map( ( result ) => { - return { - ...result, - meta: { kind: 'taxonomy', subtype }, - }; - } ); - } ) - .catch( () => [] ) // Fail by returning no results. - ); - } - - if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/search', { - search, - page, - per_page: perPage, - type: 'post-format', - subtype, - } ), - } ) - .then( ( results ) => { - return results.map( ( result ) => { - return { - ...result, - meta: { kind: 'taxonomy', subtype }, - }; - } ); - } ) - .catch( () => [] ) // Fail by returning no results. - ); - } - - if ( ! type || type === 'attachment' ) { - queries.push( - apiFetch( { - path: addQueryArgs( '/wp/v2/media', { - search, - page, - per_page: perPage, - } ), - } ) - .then( ( results ) => { - return results.map( ( result ) => { - return { - ...result, - meta: { kind: 'media' }, - }; - } ); - } ) - .catch( () => [] ) // Fail by returning no results. - ); - } - - return Promise.all( queries ).then( ( results ) => { - return results - .reduce( - ( /** @type {WPLinkSearchResult[]} */ accumulator, current ) => - accumulator.concat( current ), // Flatten list. - [] - ) - .filter( - /** - * @param {{ id: number }} result - */ - ( result ) => { - return !! result.id; - } - ) - .slice( 0, perPage ) - .map( ( /** @type {WPLinkSearchResultAugmented} */ result ) => { - const isMedia = result.type === 'attachment'; - - return { - id: result.id, - // @ts-ignore fix when we make this a TS file - url: isMedia ? result.source_url : result.url, - title: - decodeEntities( - isMedia - ? // @ts-ignore fix when we make this a TS file - result.title.rendered - : result.title || '' - ) || __( '(no title)' ), - type: result.subtype || result.type, - kind: result?.meta?.kind, - }; - } ); - } ); -}; - -export default fetchLinkSuggestions; diff --git a/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.ts b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.ts new file mode 100644 index 00000000000000..024a5931abbd81 --- /dev/null +++ b/packages/core-data/src/fetch/__experimental-fetch-link-suggestions.ts @@ -0,0 +1,296 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; + +export type SearchOptions = { + /** + * Displays initial search suggestions, when true. + */ + isInitialSuggestions?: boolean; + /** + * Search options for initial suggestions. + */ + initialSuggestionsSearchOptions?: Omit< + SearchOptions, + 'isInitialSuggestions' | 'initialSuggestionsSearchOptions' + >; + /** + * Filters by search type. + */ + type?: 'attachment' | 'post' | 'term' | 'post-format'; + /** + * Slug of the post-type or taxonomy. + */ + subtype?: string; + /** + * Which page of results to return. + */ + page?: number; + /** + * Search results per page. + */ + perPage?: number; +}; + +export type EditorSettings = { + /** + * Disables post formats, when true. + */ + disablePostFormats?: boolean; +}; + +type SearchAPIResult = { + id: number; + title: string; + url: string; + type: string; + subtype: string; +}; + +type MediaAPIResult = { + id: number; + title: { rendered: string }; + source_url: string; + type: string; +}; + +export type SearchResult = { + /** + * Post or term id. + */ + id: number; + /** + * Link url. + */ + url: string; + /** + * Title of the link. + */ + title: string; + /** + * The taxonomy or post type slug or type URL. + */ + type: string; + /** + * Link kind of post-type or taxonomy + */ + kind?: string; +}; + +/** + * Fetches link suggestions from the WordPress API. + * + * WordPress does not support searching multiple tables at once, e.g. posts and terms, so we + * perform multiple queries at the same time and then merge the results together. + * + * @param search + * @param searchOptions + * @param editorSettings + * + * @example + * ```js + * import { __experimentalFetchLinkSuggestions as fetchLinkSuggestions } from '@wordpress/core-data'; + * + * //... + * + * export function initialize( id, settings ) { + * + * settings.__experimentalFetchLinkSuggestions = ( + * search, + * searchOptions + * ) => fetchLinkSuggestions( search, searchOptions, settings ); + * ``` + */ +export default async function fetchLinkSuggestions( + search: string, + searchOptions: SearchOptions = {}, + editorSettings: EditorSettings = {} +): Promise< SearchResult[] > { + const searchOptionsToUse = + searchOptions.isInitialSuggestions && + searchOptions.initialSuggestionsSearchOptions + ? { + ...searchOptions, + ...searchOptions.initialSuggestionsSearchOptions, + } + : searchOptions; + + const { + type, + subtype, + page, + perPage = searchOptions.isInitialSuggestions ? 3 : 20, + } = searchOptionsToUse; + + const { disablePostFormats = false } = editorSettings; + + const queries: Promise< SearchResult[] >[] = []; + + if ( ! type || type === 'post' ) { + queries.push( + apiFetch< SearchAPIResult[] >( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'post', + subtype, + } ), + } ) + .then( ( results ) => { + return results.map( ( result ) => { + return { + id: result.id, + url: result.url, + title: + decodeEntities( result.title || '' ) || + __( '(no title)' ), + type: result.subtype || result.type, + kind: 'post-type', + }; + } ); + } ) + .catch( () => [] ) // Fail by returning no results. + ); + } + + if ( ! type || type === 'term' ) { + queries.push( + apiFetch< SearchAPIResult[] >( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'term', + subtype, + } ), + } ) + .then( ( results ) => { + return results.map( ( result ) => { + return { + id: result.id, + url: result.url, + title: + decodeEntities( result.title || '' ) || + __( '(no title)' ), + type: result.subtype || result.type, + kind: 'taxonomy', + }; + } ); + } ) + .catch( () => [] ) // Fail by returning no results. + ); + } + + if ( ! disablePostFormats && ( ! type || type === 'post-format' ) ) { + queries.push( + apiFetch< SearchAPIResult[] >( { + path: addQueryArgs( '/wp/v2/search', { + search, + page, + per_page: perPage, + type: 'post-format', + subtype, + } ), + } ) + .then( ( results ) => { + return results.map( ( result ) => { + return { + id: result.id, + url: result.url, + title: + decodeEntities( result.title || '' ) || + __( '(no title)' ), + type: result.subtype || result.type, + kind: 'taxonomy', + }; + } ); + } ) + .catch( () => [] ) // Fail by returning no results. + ); + } + + if ( ! type || type === 'attachment' ) { + queries.push( + apiFetch< MediaAPIResult[] >( { + path: addQueryArgs( '/wp/v2/media', { + search, + page, + per_page: perPage, + } ), + } ) + .then( ( results ) => { + return results.map( ( result ) => { + return { + id: result.id, + url: result.source_url, + title: + decodeEntities( result.title.rendered || '' ) || + __( '(no title)' ), + type: result.type, + kind: 'media', + }; + } ); + } ) + .catch( () => [] ) // Fail by returning no results. + ); + } + + const responses = await Promise.all( queries ); + + let results = responses.flat(); + results = results.filter( ( result ) => !! result.id ); + results = sortResults( results, search ); + results = results.slice( 0, perPage ); + return results; +} + +/** + * Sort search results by relevance to the given query. + * + * Sorting is necessary as we're querying multiple endpoints and merging the results. For example + * a taxonomy title might be more relevant than a post title, but by default taxonomy results will + * be ordered after all the (potentially irrelevant) post results. + * + * We sort by scoring each result, where the score is the number of tokens in the title that are + * also in the search query, divided by the total number of tokens in the title. This gives us a + * score between 0 and 1, where 1 is a perfect match. + * + * @param results + * @param search + */ +export function sortResults( results: SearchResult[], search: string ) { + const searchTokens = new Set( tokenize( search ) ); + + const scores = {}; + for ( const result of results ) { + if ( result.title ) { + const titleTokens = tokenize( result.title ); + const matchingTokens = titleTokens.filter( ( token ) => + searchTokens.has( token ) + ); + scores[ result.id ] = matchingTokens.length / titleTokens.length; + } else { + scores[ result.id ] = 0; + } + } + + return results.sort( ( a, b ) => scores[ b.id ] - scores[ a.id ] ); +} + +/** + * Turns text into an array of tokens, with whitespace and punctuation removed. + * + * For example, `"I'm having a ball."` becomes `[ "im", "having", "a", "ball" ]`. + * + * @param text + */ +export function tokenize( text: string ): string[] { + // \p{L} matches any kind of letter from any language. + // \p{N} matches any kind of numeric character. + return text.toLowerCase().match( /[\p{L}\p{N}]+/gu ) || []; +} diff --git a/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js b/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js index d283226cca0909..6878c74332c3d7 100644 --- a/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js +++ b/packages/core-data/src/fetch/test/__experimental-fetch-link-suggestions.js @@ -1,7 +1,11 @@ /** * Internal dependencies */ -import fetchLinkSuggestions from '../__experimental-fetch-link-suggestions'; +import { + default as fetchLinkSuggestions, + sortResults, + tokenize, +} from '../__experimental-fetch-link-suggestions'; jest.mock( '@wordpress/api-fetch', () => jest.fn( ( { path } ) => { @@ -317,3 +321,93 @@ describe( 'fetchLinkSuggestions', () => { ); } ); } ); + +describe( 'sortResults', () => { + it( 'returns empty array for empty results', () => { + expect( sortResults( [], '' ) ).toEqual( [] ); + } ); + + it( 'orders results', () => { + const results = [ + { + id: 1, + title: 'How to get from Stockholm to Helsinki by boat', + url: 'http://wordpress.local/stockholm-helsinki-boat/', + type: 'page', + kind: 'post-type', + }, + { + id: 2, + title: 'A day trip from Stockholm to Swedish countryside towns', + url: 'http://wordpress.local/day-trip-stockholm/', + type: 'page', + kind: 'post-type', + }, + { + id: 3, + title: 'The art of packing lightly: How to travel with just a cabin bag', + url: 'http://wordpress.local/packing-lightly/', + type: 'page', + kind: 'post-type', + }, + { + id: 4, + title: 'Tips for travel with a young baby', + url: 'http://wordpress.local/young-baby-tips/', + type: 'page', + kind: 'post-type', + }, + { + id: 5, + title: '', // Test that empty titles don't cause an error. + url: 'http://wordpress.local/420/', + type: 'page', + kind: 'post-type', + }, + { + id: 6, + title: 'City Guides', + url: 'http://wordpress.local/city-guides/', + type: 'category', + kind: 'taxonomy', + }, + { + id: 7, + title: 'Travel Tips', + url: 'http://wordpress.local/travel-tips/', + type: 'category', + kind: 'taxonomy', + }, + ]; + const order = sortResults( results, 'travel tips' ).map( + ( result ) => result.id + ); + expect( order ).toEqual( [ + 7, // exact match + 4, // contains: travel, tips + 3, // contains: travel + // same order as input: + 1, + 2, + 5, + 6, + ] ); + } ); +} ); + +describe( 'tokenize', () => { + it( 'returns empty array for empty string', () => { + expect( tokenize( '' ) ).toEqual( [] ); + } ); + + it( 'tokenizes a string', () => { + expect( tokenize( 'Hello, world!' ) ).toEqual( [ 'hello', 'world' ] ); + } ); + + it( 'tokenizes non latin languages', () => { + expect( tokenize( 'こんにちは、世界!' ) ).toEqual( [ + 'こんにちは', + '世界', + ] ); + } ); +} );