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 = {
|
||||
sortedPrefixes,
|
||||
keywords,
|
||||
partial: Object.create(null),
|
||||
partialCleanup: Date.now(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function search(
|
|||
|
||||
if (partial) {
|
||||
// Get all partial keyword matches
|
||||
const cache = getPartialKeywords(partial, data);
|
||||
const cache = getPartialKeywords(partial, true, data);
|
||||
const exists = data.keywords[partial];
|
||||
if (!cache || !cache.length) {
|
||||
// No partial matches: check if keyword exists
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import type { PartialKeywords, SearchIndexData } from '../../types/search';
|
||||
import { searchIndex } from '../search';
|
||||
|
||||
export const minPartialKeywordLength = 3;
|
||||
export const minPartialKeywordLength = 2;
|
||||
|
||||
/**
|
||||
* Find partial keywords for keyword
|
||||
*/
|
||||
export function getPartialKeywords(
|
||||
keyword: string,
|
||||
suffixes: boolean,
|
||||
data: SearchIndexData | undefined = searchIndex.data
|
||||
): PartialKeywords | undefined {
|
||||
// const data = searchIndex.data;
|
||||
|
|
@ -16,16 +17,22 @@ export function getPartialKeywords(
|
|||
return;
|
||||
}
|
||||
|
||||
if (data.partial[keyword]) {
|
||||
return data.partial[keyword];
|
||||
// Check cache
|
||||
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
|
||||
const time = Date.now();
|
||||
if (data.partialCleanup < time - 60000) {
|
||||
data.partial = Object.create(null);
|
||||
delete data.partial;
|
||||
delete data.partialPrefixes;
|
||||
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
|
||||
const prefixMatches: string[] = [];
|
||||
|
|
@ -37,14 +44,14 @@ export function getPartialKeywords(
|
|||
if (item.length > length) {
|
||||
if (item.slice(0, length) === keyword) {
|
||||
prefixMatches.push(item);
|
||||
} else if (item.slice(0 - length) === keyword) {
|
||||
} else if (suffixes && item.slice(0 - length) === keyword) {
|
||||
suffixMatches.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
.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 { generateCollectionsListResponse } from './responses/collections';
|
||||
import { generateIconsDataResponse } from './responses/icons';
|
||||
import { generateKeywordsResponse } from './responses/keywords';
|
||||
import { generateLastModifiedResponse } from './responses/modified';
|
||||
import { generateAPIv2SearchResponse } from './responses/search';
|
||||
import { generateSVGResponse } from './responses/svg';
|
||||
|
|
@ -144,6 +145,13 @@ export async function startHTTPServer() {
|
|||
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', ...]
|
||||
// Can be used for auto-completion for search results
|
||||
// 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
|
||||
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']));
|
||||
|
||||
// Check for partial keywords
|
||||
expect(getPartialKeywords('acc', searchIndex)).toEqual(['account']);
|
||||
expect(getPartialKeywords('arr', searchIndex)).toEqual(['arrow', 'arrange']);
|
||||
expect(getPartialKeywords('acc', true, searchIndex)).toEqual(['account']);
|
||||
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);
|
||||
|
||||
test('Two icon sets', async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue