feat: support query to list partial keywords for autocomplete

This commit is contained in:
Vjacheslav Trushkin 2022-11-02 22:41:36 +02:00
parent d5df65163a
commit 5883533d33
8 changed files with 139 additions and 11 deletions

View File

@ -49,7 +49,6 @@ export function updateSearchIndex(
return (searchIndex.data = {
sortedPrefixes,
keywords,
partial: Object.create(null),
partialCleanup: Date.now(),
});
}

View File

@ -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

View File

@ -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))));
}

View File

@ -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);
});
});
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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[];
};

View File

@ -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 () => {