mirror of https://github.com/iconify/api.git
feat: support query to list partial keywords for autocomplete
This commit is contained in:
parent
d5df65163a
commit
5883533d33
|
|
@ -49,7 +49,6 @@ export function updateSearchIndex(
|
||||||
return (searchIndex.data = {
|
return (searchIndex.data = {
|
||||||
sortedPrefixes,
|
sortedPrefixes,
|
||||||
keywords,
|
keywords,
|
||||||
partial: Object.create(null),
|
|
||||||
partialCleanup: Date.now(),
|
partialCleanup: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function search(
|
||||||
|
|
||||||
if (partial) {
|
if (partial) {
|
||||||
// Get all partial keyword matches
|
// Get all partial keyword matches
|
||||||
const cache = getPartialKeywords(partial, data);
|
const cache = getPartialKeywords(partial, true, data);
|
||||||
const exists = data.keywords[partial];
|
const exists = data.keywords[partial];
|
||||||
if (!cache || !cache.length) {
|
if (!cache || !cache.length) {
|
||||||
// No partial matches: check if keyword exists
|
// No partial matches: check if keyword exists
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import type { PartialKeywords, SearchIndexData } from '../../types/search';
|
import type { PartialKeywords, SearchIndexData } from '../../types/search';
|
||||||
import { searchIndex } from '../search';
|
import { searchIndex } from '../search';
|
||||||
|
|
||||||
export const minPartialKeywordLength = 3;
|
export const minPartialKeywordLength = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find partial keywords for keyword
|
* Find partial keywords for keyword
|
||||||
*/
|
*/
|
||||||
export function getPartialKeywords(
|
export function getPartialKeywords(
|
||||||
keyword: string,
|
keyword: string,
|
||||||
|
suffixes: boolean,
|
||||||
data: SearchIndexData | undefined = searchIndex.data
|
data: SearchIndexData | undefined = searchIndex.data
|
||||||
): PartialKeywords | undefined {
|
): PartialKeywords | undefined {
|
||||||
// const data = searchIndex.data;
|
// const data = searchIndex.data;
|
||||||
|
|
@ -16,16 +17,22 @@ export function getPartialKeywords(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.partial[keyword]) {
|
// Check cache
|
||||||
return data.partial[keyword];
|
const storedItem = (suffixes ? data.partial : data.partialPrefixes)?.[keyword];
|
||||||
|
if (storedItem) {
|
||||||
|
return storedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache takes a lot of memory, so clean up old cache once every few minutes before generating new item
|
// Cache takes a lot of memory, so clean up old cache once every few minutes before generating new item
|
||||||
const time = Date.now();
|
const time = Date.now();
|
||||||
if (data.partialCleanup < time - 60000) {
|
if (data.partialCleanup < time - 60000) {
|
||||||
data.partial = Object.create(null);
|
delete data.partial;
|
||||||
|
delete data.partialPrefixes;
|
||||||
data.partialCleanup = time;
|
data.partialCleanup = time;
|
||||||
}
|
}
|
||||||
|
const storageKey = suffixes ? 'partial' : 'partialPrefixes';
|
||||||
|
const storage =
|
||||||
|
data[storageKey] || (data[storageKey] = Object.create(null) as Exclude<SearchIndexData['partial'], undefined>);
|
||||||
|
|
||||||
// Generate partial list
|
// Generate partial list
|
||||||
const prefixMatches: string[] = [];
|
const prefixMatches: string[] = [];
|
||||||
|
|
@ -37,14 +44,14 @@ export function getPartialKeywords(
|
||||||
if (item.length > length) {
|
if (item.length > length) {
|
||||||
if (item.slice(0, length) === keyword) {
|
if (item.slice(0, length) === keyword) {
|
||||||
prefixMatches.push(item);
|
prefixMatches.push(item);
|
||||||
} else if (item.slice(0 - length) === keyword) {
|
} else if (suffixes && item.slice(0 - length) === keyword) {
|
||||||
suffixMatches.push(item);
|
suffixMatches.push(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: shortest matches first
|
// Sort: shortest matches first
|
||||||
return (data.partial[keyword] = prefixMatches
|
return (storage[keyword] = prefixMatches
|
||||||
.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))
|
.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))
|
||||||
.concat(suffixMatches.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))));
|
.concat(suffixMatches.sort((a, b) => (a.length === b.length ? a.localeCompare(b) : a.length - b.length))));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { generateAPIv1IconsListResponse } from './responses/collection-v1';
|
||||||
import { generateAPIv2CollectionResponse } from './responses/collection-v2';
|
import { generateAPIv2CollectionResponse } from './responses/collection-v2';
|
||||||
import { generateCollectionsListResponse } from './responses/collections';
|
import { generateCollectionsListResponse } from './responses/collections';
|
||||||
import { generateIconsDataResponse } from './responses/icons';
|
import { generateIconsDataResponse } from './responses/icons';
|
||||||
|
import { generateKeywordsResponse } from './responses/keywords';
|
||||||
import { generateLastModifiedResponse } from './responses/modified';
|
import { generateLastModifiedResponse } from './responses/modified';
|
||||||
import { generateAPIv2SearchResponse } from './responses/search';
|
import { generateAPIv2SearchResponse } from './responses/search';
|
||||||
import { generateSVGResponse } from './responses/svg';
|
import { generateSVGResponse } from './responses/svg';
|
||||||
|
|
@ -144,6 +145,13 @@ export async function startHTTPServer() {
|
||||||
generateAPIv2SearchResponse(req.query, res);
|
generateAPIv2SearchResponse(req.query, res);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keywords
|
||||||
|
server.get('/keywords', (req, res) => {
|
||||||
|
runWhenLoaded(() => {
|
||||||
|
generateKeywordsResponse(req.query, res);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { matchIconName } from '@iconify/utils';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getPrefixes, iconSets } from '../../data/icon-sets';
|
||||||
|
import { searchIndex } from '../../data/search';
|
||||||
|
import { getPartialKeywords } from '../../data/search/partial';
|
||||||
|
import type { APIv3KeywordsQuery, APIv3KeywordsResponse } from '../../types/server/keywords';
|
||||||
|
import type { APIv3LastModifiedResponse } from '../../types/server/modified';
|
||||||
|
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json';
|
||||||
|
import { filterPrefixesByPrefix } from '../helpers/prefixes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate icons data
|
||||||
|
*/
|
||||||
|
export function generateKeywordsResponse(query: FastifyRequest['query'], res: FastifyReply) {
|
||||||
|
const q = (query || {}) as Record<string, string>;
|
||||||
|
const wrap = checkJSONPQuery(q);
|
||||||
|
if (!wrap) {
|
||||||
|
// Invalid JSONP callback
|
||||||
|
res.send(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if search data is available
|
||||||
|
const searchIndexData = searchIndex.data;
|
||||||
|
if (!searchIndexData) {
|
||||||
|
res.send(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const keywords = searchIndexData.keywords;
|
||||||
|
|
||||||
|
// Get params
|
||||||
|
let test: string;
|
||||||
|
let suffixes: boolean;
|
||||||
|
let invalid: true | undefined;
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
if (typeof q.prefix === 'string') {
|
||||||
|
test = q.prefix;
|
||||||
|
suffixes = false;
|
||||||
|
} else if (typeof q.keyword === 'string') {
|
||||||
|
test = q.keyword;
|
||||||
|
suffixes = true;
|
||||||
|
} else {
|
||||||
|
// Invalid query
|
||||||
|
res.send(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
test = test.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Check if keyword is invalid
|
||||||
|
if (!matchIconName.test(test)) {
|
||||||
|
invalid = true;
|
||||||
|
} else {
|
||||||
|
// Get only last part of complex keyword
|
||||||
|
// Testing complex keywords is not recommended, first part is not checked
|
||||||
|
const parts = test.split('-');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
test = parts.pop() as string;
|
||||||
|
suffixes = false;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
if (keywords[parts[i]] === void 0) {
|
||||||
|
// One of keywords is missing
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate result
|
||||||
|
const response: APIv3KeywordsResponse = {
|
||||||
|
...(q as unknown as APIv3KeywordsQuery),
|
||||||
|
invalid,
|
||||||
|
exists: failed ? false : keywords[test] !== void 0,
|
||||||
|
matches: failed || invalid ? [] : getPartialKeywords(test, suffixes, searchIndexData)?.slice(0) || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
sendJSONResponse(response, q, wrap, res);
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,8 @@ export interface SearchIndexData {
|
||||||
// Partial keywords: ['foo'] = ['foo1', 'foo2', 'foobar', ...]
|
// Partial keywords: ['foo'] = ['foo1', 'foo2', 'foobar', ...]
|
||||||
// Can be used for auto-completion for search results
|
// Can be used for auto-completion for search results
|
||||||
// Keywords are generated on demand and sorted by length: shortest first
|
// Keywords are generated on demand and sorted by length: shortest first
|
||||||
partial: Record<string, PartialKeywords>;
|
partial?: Record<string, PartialKeywords>;
|
||||||
|
partialPrefixes?: Record<string, PartialKeywords>;
|
||||||
|
|
||||||
// Last cleanup for old partial lists
|
// Last cleanup for old partial lists
|
||||||
partialCleanup: number;
|
partialCleanup: number;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* Parameters for `/keywords` query
|
||||||
|
*
|
||||||
|
* One of `prefix` or `keyword` parameters must be set
|
||||||
|
*/
|
||||||
|
export interface APIv3KeywordsPrefixQuery {
|
||||||
|
// Prefix to test: matches for 'foo' include 'foobar', but not 'barfoo'
|
||||||
|
prefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIv3KeywordsFullQuery {
|
||||||
|
// Keyword to test: matches for 'foo' include 'foobar' and 'barfoo'
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type APIv3KeywordsQuery = APIv3KeywordsPrefixQuery | APIv3KeywordsFullQuery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for /keywords query
|
||||||
|
*
|
||||||
|
* Includes request + response
|
||||||
|
*/
|
||||||
|
export type APIv3KeywordsResponse = APIv3KeywordsQuery & {
|
||||||
|
// Set to true if keyword is invalid
|
||||||
|
invalid?: true;
|
||||||
|
|
||||||
|
// True if partial keyword exists as is
|
||||||
|
exists: boolean;
|
||||||
|
|
||||||
|
// Keywords that contain partial keyword
|
||||||
|
matches: string[];
|
||||||
|
};
|
||||||
|
|
@ -52,8 +52,11 @@ describe('Creating search index, checking prefixes', () => {
|
||||||
expect(searchIndex.keywords['xml']).toEqual(new Set(['mdi-light']));
|
expect(searchIndex.keywords['xml']).toEqual(new Set(['mdi-light']));
|
||||||
|
|
||||||
// Check for partial keywords
|
// Check for partial keywords
|
||||||
expect(getPartialKeywords('acc', searchIndex)).toEqual(['account']);
|
expect(getPartialKeywords('acc', true, searchIndex)).toEqual(['account']);
|
||||||
expect(getPartialKeywords('arr', searchIndex)).toEqual(['arrow', 'arrange']);
|
expect(getPartialKeywords('arr', true, searchIndex)).toEqual(['arrow', 'arrange']);
|
||||||
|
expect(getPartialKeywords('row', true, searchIndex)).toEqual(['arrow']);
|
||||||
|
expect(getPartialKeywords('one', true, searchIndex)).toEqual(['none', 'phone', 'microphone']);
|
||||||
|
expect(getPartialKeywords('one', false, searchIndex)).toEqual([]);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
test('Two icon sets', async () => {
|
test('Two icon sets', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue