mirror of https://github.com/iconify/api.git
feat: search engine
This commit is contained in:
parent
df42beb8c3
commit
d542004aab
|
|
@ -192,5 +192,31 @@ export function generateIconSetIconsTree(iconSet: IconifyJSON): IconSetIconsList
|
||||||
result.chars = chars;
|
result.chars = chars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate keywords for all visible icons if:
|
||||||
|
// - search engine is enabled
|
||||||
|
// - icon set has info (cannot search icon set if cannot show it)
|
||||||
|
// - icon set is not marked as hidden
|
||||||
|
if (appConfig.enableIconLists && appConfig.enableSearchEngine && iconSet.info && !iconSet.info.hidden) {
|
||||||
|
const keywords = (result.keywords = Object.create(null) as Record<string, Set<IconSetIconNames>>);
|
||||||
|
for (const name in visible) {
|
||||||
|
const icon = visible[name];
|
||||||
|
if (icon[0] !== name) {
|
||||||
|
// Alias. Another entry for parent icon should be present in `visible` object
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconKeywords: Set<string> = new Set();
|
||||||
|
for (let i = 0; i < icon.length; i++) {
|
||||||
|
icon[i].split('-').forEach((chunk) => {
|
||||||
|
if (iconKeywords.has(chunk)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
iconKeywords.add(chunk);
|
||||||
|
(keywords[chunk] || (keywords[chunk] = new Set())).add(icon);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,18 @@ export function asyncStoreLoadedIconSet(
|
||||||
config: SplitIconSetConfig = splitIconSetConfig
|
config: SplitIconSetConfig = splitIconSetConfig
|
||||||
): Promise<StoredIconSet> {
|
): Promise<StoredIconSet> {
|
||||||
return new Promise((fulfill) => {
|
return new Promise((fulfill) => {
|
||||||
storeLoadedIconSet(iconSet, fulfill, storage, config);
|
storeLoadedIconSet(
|
||||||
|
iconSet,
|
||||||
|
(data: StoredIconSet) => {
|
||||||
|
// Purge unused memory if garbage collector global is exposed
|
||||||
|
try {
|
||||||
|
global.gc?.();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
fulfill(data);
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
config
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { StoredIconSet } from '../types/icon-set/storage';
|
import type { StoredIconSet } from '../types/icon-set/storage';
|
||||||
import type { IconSetEntry, Importer } from '../types/importers';
|
import type { IconSetEntry, Importer } from '../types/importers';
|
||||||
|
import { updateSearchIndex } from './search';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All importers
|
* All importers
|
||||||
|
|
@ -100,6 +101,8 @@ export function updateIconSets(): number {
|
||||||
if (loadedIconSets.size) {
|
if (loadedIconSets.size) {
|
||||||
// Got some icon sets to clean up
|
// Got some icon sets to clean up
|
||||||
const cleanup = loadedIconSets;
|
const cleanup = loadedIconSets;
|
||||||
|
|
||||||
|
// TODO: clean up old icon sets
|
||||||
}
|
}
|
||||||
loadedIconSets = newLoadedIconSets;
|
loadedIconSets = newLoadedIconSets;
|
||||||
|
|
||||||
|
|
@ -107,6 +110,15 @@ export function updateIconSets(): number {
|
||||||
allPrefixes = Array.from(newPrefixes);
|
allPrefixes = Array.from(newPrefixes);
|
||||||
prefixesWithInfo = Array.from(newPrefixesWithInfo);
|
prefixesWithInfo = Array.from(newPrefixesWithInfo);
|
||||||
visiblePrefixes = Array.from(newVisiblePrefixes);
|
visiblePrefixes = Array.from(newVisiblePrefixes);
|
||||||
|
|
||||||
|
// Update search index
|
||||||
|
updateSearchIndex(allPrefixes, iconSets);
|
||||||
|
|
||||||
|
// Purge unused memory if garbage collector global is exposed
|
||||||
|
try {
|
||||||
|
global.gc?.();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return allPrefixes.length;
|
return allPrefixes.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { appConfig } from '../config/app';
|
||||||
|
import type { IconSetEntry } from '../types/importers';
|
||||||
|
import type { SearchIndexData } from '../types/search';
|
||||||
|
|
||||||
|
interface SearchIndex {
|
||||||
|
data?: SearchIndexData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search data
|
||||||
|
*/
|
||||||
|
export const searchIndex: SearchIndex = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update search index
|
||||||
|
*/
|
||||||
|
export function updateSearchIndex(
|
||||||
|
prefixes: string[],
|
||||||
|
iconSets: Record<string, IconSetEntry>
|
||||||
|
): SearchIndexData | undefined {
|
||||||
|
if (!appConfig.enableIconLists || !appConfig.enableSearchEngine) {
|
||||||
|
// Search engine is disabled
|
||||||
|
delete searchIndex.data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all icon sets
|
||||||
|
const sortedPrefixes: string[] = [];
|
||||||
|
const keywords = Object.create(null) as Record<string, Set<string>>;
|
||||||
|
for (let i = 0; i < prefixes.length; i++) {
|
||||||
|
const prefix = prefixes[i];
|
||||||
|
const iconSet = iconSets[prefix]?.item;
|
||||||
|
if (!iconSet) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSetKeywords = iconSet.icons.keywords;
|
||||||
|
if (!iconSetKeywords) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedPrefixes.push(prefix);
|
||||||
|
for (const keyword in iconSetKeywords) {
|
||||||
|
(keywords[keyword] || (keywords[keyword] = new Set())).add(prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set data
|
||||||
|
return (searchIndex.data = {
|
||||||
|
sortedPrefixes,
|
||||||
|
keywords,
|
||||||
|
partial: Object.create(null),
|
||||||
|
partialCleanup: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import type { IconSetIconNames } from '../../types/icon-set/extra';
|
||||||
|
import type { IconSetEntry } from '../../types/importers';
|
||||||
|
import type { SearchIndexData, SearchKeywordsEntry, SearchParams, SearchResultsData } from '../../types/search';
|
||||||
|
import { getPartialKeywords } from './partial';
|
||||||
|
import { filterSearchPrefixes, filterSearchPrefixesList } from './prefixes';
|
||||||
|
import { splitKeyword } from './split';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run search
|
||||||
|
*/
|
||||||
|
export function search(
|
||||||
|
params: SearchParams,
|
||||||
|
data: SearchIndexData,
|
||||||
|
iconSets: Record<string, IconSetEntry>
|
||||||
|
): SearchResultsData | undefined {
|
||||||
|
// Get keywords
|
||||||
|
const keywords = splitKeyword(params.keyword, params.partial);
|
||||||
|
if (!keywords) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure all keywords exist
|
||||||
|
keywords.searches = keywords.searches.filter((search) => {
|
||||||
|
for (let i = 0; i < search.keywords.length; i++) {
|
||||||
|
if (!data.keywords[search.keywords[i]]) {
|
||||||
|
// One of required keywords is missing: no point in searching
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (!keywords.searches.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for partial
|
||||||
|
const partial = keywords.partial;
|
||||||
|
let partialKeywords: string[] | undefined;
|
||||||
|
|
||||||
|
if (partial) {
|
||||||
|
// Get all partial keyword matches
|
||||||
|
const cache = getPartialKeywords(partial, data);
|
||||||
|
const exists = data.keywords[partial];
|
||||||
|
if (!cache || !cache.length) {
|
||||||
|
// No partial matches: check if keyword exists
|
||||||
|
if (!exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
partialKeywords = [partial];
|
||||||
|
} else {
|
||||||
|
// Partial keywords exist
|
||||||
|
partialKeywords = exists ? [partial].concat(cache) : cache.slice(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prefixes
|
||||||
|
const basePrefixes = filterSearchPrefixes(data, iconSets, {
|
||||||
|
...params,
|
||||||
|
// Params extracted from query override default params
|
||||||
|
...keywords.params,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare variables
|
||||||
|
const addedIcons = Object.create(null) as Record<string, Set<IconSetIconNames>>;
|
||||||
|
const foundPrefixes: Set<string> = new Set();
|
||||||
|
const results: string[] = [];
|
||||||
|
const limit = params.limit;
|
||||||
|
|
||||||
|
// Run all searches
|
||||||
|
const check = (partial?: string) => {
|
||||||
|
for (let searchIndex = 0; searchIndex < keywords.searches.length; searchIndex++) {
|
||||||
|
// Add prefixes cache to avoid re-calculating it for every partial keyword
|
||||||
|
interface ExtendedSearchKeywordsEntry extends SearchKeywordsEntry {
|
||||||
|
filteredPrefixes?: Readonly<string[]>;
|
||||||
|
}
|
||||||
|
const search = keywords.searches[searchIndex] as ExtendedSearchKeywordsEntry;
|
||||||
|
|
||||||
|
// Filter prefixes (or get it from cache)
|
||||||
|
let filteredPrefixes: Readonly<string[]>;
|
||||||
|
if (search.filteredPrefixes) {
|
||||||
|
filteredPrefixes = search.filteredPrefixes;
|
||||||
|
} else {
|
||||||
|
filteredPrefixes = search.prefixes
|
||||||
|
? filterSearchPrefixesList(basePrefixes, search.prefixes)
|
||||||
|
: basePrefixes;
|
||||||
|
|
||||||
|
// Filter by required keywords
|
||||||
|
for (let i = 0; i < search.keywords.length; i++) {
|
||||||
|
filteredPrefixes = filteredPrefixes.filter((prefix) =>
|
||||||
|
data.keywords[search.keywords[i]].has(prefix)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
search.filteredPrefixes = filteredPrefixes;
|
||||||
|
}
|
||||||
|
if (!filteredPrefixes.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get keywords
|
||||||
|
const testKeywords = partial ? search.keywords.concat([partial]) : search.keywords;
|
||||||
|
const testMatches = search.test ? search.test.concat(testKeywords) : testKeywords;
|
||||||
|
|
||||||
|
// Check for partial keyword if testing for exact match
|
||||||
|
if (partial) {
|
||||||
|
filteredPrefixes = filteredPrefixes.filter((prefix) => data.keywords[partial].has(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check icons
|
||||||
|
for (let prefixIndex = 0; prefixIndex < filteredPrefixes.length; prefixIndex++) {
|
||||||
|
const prefix = filteredPrefixes[prefixIndex];
|
||||||
|
const prefixAddedIcons = addedIcons[prefix] || (addedIcons[prefix] = new Set());
|
||||||
|
const iconSet = iconSets[prefix].item;
|
||||||
|
const iconSetIcons = iconSet.icons;
|
||||||
|
const iconSetKeywords = iconSetIcons.keywords;
|
||||||
|
if (!iconSetKeywords) {
|
||||||
|
// This should not happen!
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check icons in current prefix
|
||||||
|
let matches: IconSetIconNames[] | undefined;
|
||||||
|
let failed = false;
|
||||||
|
for (let keywordIndex = 0; keywordIndex < testKeywords.length && !failed; keywordIndex++) {
|
||||||
|
const keyword = testKeywords[keywordIndex];
|
||||||
|
const keywordMatches = iconSetKeywords[keyword];
|
||||||
|
if (!keywordMatches) {
|
||||||
|
failed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
// Copy all matches
|
||||||
|
matches = Array.from(keywordMatches);
|
||||||
|
} else {
|
||||||
|
// Match previous set
|
||||||
|
matches = matches.filter((item) => keywordMatches.has(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test matched icons
|
||||||
|
if (!failed && matches) {
|
||||||
|
for (let matchIndex = 0; matchIndex < matches.length; matchIndex++) {
|
||||||
|
const item = matches[matchIndex];
|
||||||
|
if (prefixAddedIcons.has(item)) {
|
||||||
|
// Already added
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find icon name that matches all keywords
|
||||||
|
const name = item.find((name) => {
|
||||||
|
for (let i = 0; i < testMatches.length; i++) {
|
||||||
|
if (name.indexOf(testMatches[i]) === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (name) {
|
||||||
|
// Add icon
|
||||||
|
prefixAddedIcons.add(item);
|
||||||
|
results.push(prefix + ':' + name);
|
||||||
|
if (results.length >= limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check all keywords
|
||||||
|
if (!partialKeywords) {
|
||||||
|
check();
|
||||||
|
} else {
|
||||||
|
let partial: string | undefined;
|
||||||
|
while ((partial = partialKeywords.shift())) {
|
||||||
|
check(partial);
|
||||||
|
if (results.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate results
|
||||||
|
if (results.length) {
|
||||||
|
return {
|
||||||
|
prefixes: Object.keys(addedIcons),
|
||||||
|
names: results,
|
||||||
|
hasMore: results.length >= limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { PartialKeywords, SearchIndexData } from '../../types/search';
|
||||||
|
import { searchIndex } from '../search';
|
||||||
|
|
||||||
|
export const minPartialKeywordLength = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find partial keywords for keyword
|
||||||
|
*/
|
||||||
|
export function getPartialKeywords(
|
||||||
|
keyword: string,
|
||||||
|
data: SearchIndexData | undefined = searchIndex.data
|
||||||
|
): PartialKeywords | undefined {
|
||||||
|
// const data = searchIndex.data;
|
||||||
|
const length = keyword.length;
|
||||||
|
if (!data || length < minPartialKeywordLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.partial[keyword]) {
|
||||||
|
return data.partial[keyword];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
data.partialCleanup = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate partial list
|
||||||
|
const prefixMatches: string[] = [];
|
||||||
|
const suffixMatches: string[] = [];
|
||||||
|
|
||||||
|
// Find similar keywords
|
||||||
|
const keywords = data.keywords;
|
||||||
|
for (const item in keywords) {
|
||||||
|
if (item.length > length) {
|
||||||
|
if (item.slice(0, length) === keyword) {
|
||||||
|
prefixMatches.push(item);
|
||||||
|
} else if (item.slice(0 - length) === keyword) {
|
||||||
|
suffixMatches.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: shortest matches first
|
||||||
|
return (data.partial[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))));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { IconSetEntry } from '../../types/importers';
|
||||||
|
import type { SearchIndexData, SearchParams } from '../../types/search';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter prefixes by keyword
|
||||||
|
*/
|
||||||
|
export function filterSearchPrefixesList(prefixes: readonly string[], filters: string[]): string[] {
|
||||||
|
const set = new Set(filters);
|
||||||
|
const hasPartial = !!filters.find((item) => item.slice(-1) === '-');
|
||||||
|
return prefixes.filter((prefix) => {
|
||||||
|
if (set.has(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hasPartial) {
|
||||||
|
// Check for partial matches
|
||||||
|
const parts = prefix.split('-');
|
||||||
|
let test = '';
|
||||||
|
while (parts.length > 1) {
|
||||||
|
test += parts.shift() + '-';
|
||||||
|
if (set.has(test)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter prefixes
|
||||||
|
*/
|
||||||
|
export function filterSearchPrefixes(
|
||||||
|
data: SearchIndexData,
|
||||||
|
iconSets: Record<string, IconSetEntry>,
|
||||||
|
params: SearchParams
|
||||||
|
): Readonly<string[]> {
|
||||||
|
let prefixes: string[] | undefined;
|
||||||
|
|
||||||
|
// Filter by prefix
|
||||||
|
if (params.prefixes) {
|
||||||
|
prefixes = filterSearchPrefixesList(prefixes || data.sortedPrefixes, params.prefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by palette
|
||||||
|
const palette = params.palette;
|
||||||
|
if (typeof palette === 'boolean') {
|
||||||
|
prefixes = (prefixes || data.sortedPrefixes).filter((prefix) => {
|
||||||
|
const info = iconSets[prefix].item.info;
|
||||||
|
return info?.palette === palette;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add more filter options
|
||||||
|
|
||||||
|
return prefixes || data.sortedPrefixes;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
import { matchIconName } from '@iconify/utils/lib/icon/name';
|
||||||
|
import { paramToBoolean } from '../../misc/bool';
|
||||||
|
import type { SearchKeywords, SearchKeywordsEntry } from '../../types/search';
|
||||||
|
import { minPartialKeywordLength } from './partial';
|
||||||
|
|
||||||
|
interface SplitOptions {
|
||||||
|
// Can include prefix
|
||||||
|
prefix: boolean;
|
||||||
|
|
||||||
|
// Can be partial
|
||||||
|
partial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitResultItem {
|
||||||
|
// Icon set prefix
|
||||||
|
prefix?: string;
|
||||||
|
|
||||||
|
// List of exact matches
|
||||||
|
keywords: string[];
|
||||||
|
|
||||||
|
// Strings to test icon name
|
||||||
|
test?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitResult {
|
||||||
|
searches: SplitResultItem[];
|
||||||
|
|
||||||
|
// Partial keyword. It is last chunk of last keyword, which cannot be treated
|
||||||
|
// as prefix, so it is identical to all searches
|
||||||
|
partial?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitKeywordEntries(values: string[], options: SplitOptions): SplitResult | undefined {
|
||||||
|
const results: SplitResult = {
|
||||||
|
searches: [],
|
||||||
|
};
|
||||||
|
let invalid = false;
|
||||||
|
|
||||||
|
// Split each entry
|
||||||
|
interface Entry {
|
||||||
|
value: string;
|
||||||
|
empty: boolean;
|
||||||
|
}
|
||||||
|
const splitValues: Entry[][] = [];
|
||||||
|
values.forEach((item) => {
|
||||||
|
const entries: Entry[] = [];
|
||||||
|
let hasValue = false;
|
||||||
|
|
||||||
|
const parts = item.split('-');
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const value = parts[i];
|
||||||
|
const empty = !value;
|
||||||
|
if (!empty && !matchIconName.test(value)) {
|
||||||
|
// Invalid entry
|
||||||
|
invalid = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
value,
|
||||||
|
empty,
|
||||||
|
});
|
||||||
|
hasValue = hasValue || !empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
splitValues.push(entries);
|
||||||
|
if (!hasValue) {
|
||||||
|
invalid = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (invalid || !splitValues.length) {
|
||||||
|
// Something went wrong
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert value to test string, returns undefined if it is a simple keyword
|
||||||
|
function valuesToString(items: Entry[]): string | undefined {
|
||||||
|
if (!items.length || (items.length === 1 && !items[0].empty)) {
|
||||||
|
// Empty or only one keyword
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return (items[0].empty ? '-' : '') + items.map((item) => item.value).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to add item
|
||||||
|
function add(items: Entry[], keywords: Set<string>, test: Set<string>, checkPartial: boolean) {
|
||||||
|
let partial: string | undefined;
|
||||||
|
|
||||||
|
// Add keywords
|
||||||
|
const max = items.length - 1;
|
||||||
|
for (let i = 0; i <= max; i++) {
|
||||||
|
const value = items[i];
|
||||||
|
if (!value.empty) {
|
||||||
|
if (i === max && checkPartial && value.value.length >= minPartialKeywordLength) {
|
||||||
|
partial = value.value;
|
||||||
|
} else {
|
||||||
|
keywords.add(value.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get test value
|
||||||
|
const testValue = valuesToString(items);
|
||||||
|
if (testValue) {
|
||||||
|
test.add(testValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate partial
|
||||||
|
if (checkPartial) {
|
||||||
|
if (results.searches.length) {
|
||||||
|
if (results.partial !== partial) {
|
||||||
|
// Partial should be identical for all searches. Something went wrong !!!
|
||||||
|
console.error('Mismatches partials when splitting keywords:', values);
|
||||||
|
delete results.partial;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results.partial = partial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items
|
||||||
|
const lastIndex = splitValues.length - 1;
|
||||||
|
if (options.prefix) {
|
||||||
|
const firstItem = splitValues[0];
|
||||||
|
const maxFirstItemIndex = firstItem.length - 1;
|
||||||
|
|
||||||
|
// Add with first keyword as prefix
|
||||||
|
if (lastIndex) {
|
||||||
|
// Check for empty item. It can only be present at the end of value
|
||||||
|
const emptyItem = firstItem.find((item) => item.empty);
|
||||||
|
if (!emptyItem || (maxFirstItemIndex > 0 && emptyItem === firstItem[maxFirstItemIndex])) {
|
||||||
|
const prefix = firstItem.length > 1 ? valuesToString(firstItem) : firstItem[0].value;
|
||||||
|
if (prefix) {
|
||||||
|
// Valid prefix
|
||||||
|
const keywords: Set<string> = new Set();
|
||||||
|
const test: Set<string> = new Set();
|
||||||
|
for (let i = 1; i <= lastIndex; i++) {
|
||||||
|
add(splitValues[i], keywords, test, options.partial && i === lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywords.size || results.partial) {
|
||||||
|
const item: SplitResultItem = {
|
||||||
|
keywords: Array.from(keywords),
|
||||||
|
prefix,
|
||||||
|
};
|
||||||
|
if (test.size) {
|
||||||
|
item.test = Array.from(test);
|
||||||
|
}
|
||||||
|
results.searches.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add with first part of first keyword as prefix
|
||||||
|
// First 2 items cannot be empty
|
||||||
|
if (maxFirstItemIndex && !firstItem[0].empty && !firstItem[1].empty) {
|
||||||
|
const modifiedFirstItem = firstItem.slice(0);
|
||||||
|
const prefix = modifiedFirstItem.shift()!.value;
|
||||||
|
const keywords: Set<string> = new Set();
|
||||||
|
const test: Set<string> = new Set();
|
||||||
|
for (let i = 0; i <= lastIndex; i++) {
|
||||||
|
add(i ? splitValues[i] : modifiedFirstItem, keywords, test, options.partial && i === lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywords.size || results.partial) {
|
||||||
|
const item: SplitResultItem = {
|
||||||
|
keywords: Array.from(keywords),
|
||||||
|
prefix,
|
||||||
|
};
|
||||||
|
if (test.size) {
|
||||||
|
item.test = Array.from(test);
|
||||||
|
}
|
||||||
|
results.searches.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add as is
|
||||||
|
const keywords: Set<string> = new Set();
|
||||||
|
const test: Set<string> = new Set();
|
||||||
|
for (let i = 0; i <= lastIndex; i++) {
|
||||||
|
add(splitValues[i], keywords, test, options.partial && i === lastIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keywords.size || results.partial) {
|
||||||
|
const item: SplitResultItem = {
|
||||||
|
keywords: Array.from(keywords),
|
||||||
|
};
|
||||||
|
if (test.size) {
|
||||||
|
item.test = Array.from(test);
|
||||||
|
}
|
||||||
|
results.searches.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle partial prefix
|
||||||
|
*/
|
||||||
|
function addPartialPrefix(prefix: string, set: Set<string>): boolean {
|
||||||
|
if (prefix.slice(-1) === '*') {
|
||||||
|
// Wildcard entry
|
||||||
|
prefix = prefix.slice(0, prefix.length - 1);
|
||||||
|
if (matchIconName.test(prefix)) {
|
||||||
|
set.add(prefix);
|
||||||
|
set.add(prefix + '-');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (prefix.length && matchIconName.test(prefix + 'a')) {
|
||||||
|
// Add 'a' to allow partial prefixes like 'mdi-'
|
||||||
|
set.add(prefix);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split keyword
|
||||||
|
*/
|
||||||
|
export function splitKeyword(keyword: string, allowPartial = true): SearchKeywords | undefined {
|
||||||
|
const commonPrefixes: Set<string> = new Set();
|
||||||
|
let palette: boolean | undefined;
|
||||||
|
|
||||||
|
// Split by space, check for prefixes and reserved keywords
|
||||||
|
const keywordChunks = keyword.toLowerCase().trim().split(/\s+/);
|
||||||
|
const keywords: string[] = [];
|
||||||
|
let hasPrefixes = false;
|
||||||
|
let checkPartial = false;
|
||||||
|
for (let i = 0; i < keywordChunks.length; i++) {
|
||||||
|
const part = keywordChunks[i];
|
||||||
|
const prefixChunks = part.split(':') as string[];
|
||||||
|
|
||||||
|
if (prefixChunks.length > 2) {
|
||||||
|
// Too many prefixes: invalidate search query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for prefix or reserved keyword
|
||||||
|
if (prefixChunks.length === 2) {
|
||||||
|
const keyword = prefixChunks[0];
|
||||||
|
const value = prefixChunks[1];
|
||||||
|
switch (keyword) {
|
||||||
|
case 'palette': {
|
||||||
|
palette = paramToBoolean(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'prefix':
|
||||||
|
case 'prefixes': {
|
||||||
|
// Prefixes
|
||||||
|
if (hasPrefixes) {
|
||||||
|
// Already had entry with prefix: invalidate query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = value.split(',');
|
||||||
|
let invalid = true;
|
||||||
|
hasPrefixes = true;
|
||||||
|
for (let j = 0; j < values.length; j++) {
|
||||||
|
if (addPartialPrefix(values[j].trim(), commonPrefixes)) {
|
||||||
|
invalid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalid) {
|
||||||
|
// All prefixes are bad: invalidate search query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Icon with prefix
|
||||||
|
if (hasPrefixes) {
|
||||||
|
// Already had entry with prefix: invalidate query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = keyword.split(',');
|
||||||
|
let invalid = true;
|
||||||
|
hasPrefixes = true;
|
||||||
|
for (let j = 0; j < values.length; j++) {
|
||||||
|
const prefix = values[j].trim();
|
||||||
|
if (matchIconName.test(prefix)) {
|
||||||
|
commonPrefixes.add(prefix);
|
||||||
|
invalid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalid) {
|
||||||
|
// All prefixes are bad: invalidate search query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length) {
|
||||||
|
// Add icon name, unless it is empty: 'mdi:'
|
||||||
|
// Allow partial if enabled
|
||||||
|
checkPartial = allowPartial;
|
||||||
|
keywords.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 part
|
||||||
|
// Check for 'key=value' pairs
|
||||||
|
const paramChunks = part.split('=');
|
||||||
|
if (paramChunks.length > 2) {
|
||||||
|
// Bad query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paramChunks.length === 2) {
|
||||||
|
const value = paramChunks[1] as string;
|
||||||
|
switch (paramChunks[0]) {
|
||||||
|
// 'palette=true', 'palette=false' -> filter icon sets by palette
|
||||||
|
case 'palette':
|
||||||
|
palette = paramToBoolean(value);
|
||||||
|
if (typeof palette !== 'boolean') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 'prefix=material-symbols', 'prefix=material-'
|
||||||
|
// 'prefixes=material-symbols,material-'
|
||||||
|
case 'prefix':
|
||||||
|
case 'prefixes':
|
||||||
|
if (hasPrefixes) {
|
||||||
|
// Already had entry with prefix: invalidate query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let invalid = true;
|
||||||
|
const values = value.split(',');
|
||||||
|
for (let j = 0; j < values.length; j++) {
|
||||||
|
if (addPartialPrefix(values[j].trim(), commonPrefixes)) {
|
||||||
|
invalid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalid) {
|
||||||
|
// All prefixes are bad: invalidate search query
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Unknown keyword
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple keyword. Allow partial if enabled
|
||||||
|
checkPartial = allowPartial;
|
||||||
|
keywords.push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keywords.length) {
|
||||||
|
// No keywords
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = splitKeywordEntries(keywords, {
|
||||||
|
prefix: !hasPrefixes && !commonPrefixes.size,
|
||||||
|
partial: checkPartial,
|
||||||
|
});
|
||||||
|
if (!entries) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searches: SearchKeywordsEntry[] = entries.searches.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
prefixes: item.prefix
|
||||||
|
? [...commonPrefixes, item.prefix]
|
||||||
|
: commonPrefixes.size
|
||||||
|
? [...commonPrefixes]
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const params: SearchKeywords['params'] = {};
|
||||||
|
if (typeof palette === 'boolean') {
|
||||||
|
params.palette = palette;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
searches,
|
||||||
|
params,
|
||||||
|
partial: entries.partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -37,11 +37,6 @@ export function cleanupStoredItem<T>(storage: MemoryStorage<T>, storedItem: Memo
|
||||||
stopTimer(storage);
|
stopTimer(storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purge unused memory if garbage collector global is exposed
|
|
||||||
try {
|
|
||||||
global.gc?.();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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 { generateLastModifiedResponse } from './responses/modified';
|
import { generateLastModifiedResponse } from './responses/modified';
|
||||||
|
import { generateAPIv2SearchResponse } from './responses/search';
|
||||||
import { generateSVGResponse } from './responses/svg';
|
import { generateSVGResponse } from './responses/svg';
|
||||||
import { generateUpdateResponse } from './responses/update';
|
import { generateUpdateResponse } from './responses/update';
|
||||||
import { initVersionResponse, versionResponse } from './responses/version';
|
import { initVersionResponse, versionResponse } from './responses/version';
|
||||||
|
|
@ -135,6 +136,15 @@ export async function startHTTPServer() {
|
||||||
generateAPIv1IconsListResponse(req.query, res, true);
|
generateAPIv1IconsListResponse(req.query, res, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (appConfig.enableSearchEngine) {
|
||||||
|
// Search, currently version 2
|
||||||
|
server.get('/search', (req, res) => {
|
||||||
|
runWhenLoaded(() => {
|
||||||
|
generateAPIv2SearchResponse(req.query, res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update icon sets
|
// Update icon sets
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { iconSets } from '../../data/icon-sets';
|
||||||
|
import { searchIndex } from '../../data/search';
|
||||||
|
import { search } from '../../data/search/index';
|
||||||
|
import { paramToBoolean } from '../../misc/bool';
|
||||||
|
import type { SearchParams } from '../../types/search';
|
||||||
|
import type { APIv2SearchParams, APIv2SearchResponse } from '../../types/server/v2';
|
||||||
|
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json';
|
||||||
|
|
||||||
|
const minSearchLimit = 32;
|
||||||
|
const maxSearchLimit = 999;
|
||||||
|
const defaultSearchLimit = maxSearchLimit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send API v2 response
|
||||||
|
*/
|
||||||
|
export function generateAPIv2SearchResponse(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get query
|
||||||
|
const keyword = q.query;
|
||||||
|
if (!keyword) {
|
||||||
|
res.send(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to params
|
||||||
|
const params: SearchParams = {
|
||||||
|
keyword,
|
||||||
|
limit: defaultSearchLimit,
|
||||||
|
};
|
||||||
|
const v2Query = q as unknown as Record<keyof APIv2SearchParams, string>;
|
||||||
|
|
||||||
|
// Get limits
|
||||||
|
if (v2Query.limit) {
|
||||||
|
const limit = parseInt(v2Query.limit);
|
||||||
|
if (!limit) {
|
||||||
|
res.send(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
params.limit = Math.max(minSearchLimit, Math.min(limit, maxSearchLimit));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
if (v2Query.start) {
|
||||||
|
start = parseInt(v2Query.start);
|
||||||
|
if (isNaN(start) || start < 0 || start >= params.limit) {
|
||||||
|
res.send(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prefixes
|
||||||
|
if (v2Query.prefixes) {
|
||||||
|
params.prefixes = v2Query.prefixes.split(',');
|
||||||
|
} else if (v2Query.prefix) {
|
||||||
|
params.prefixes = [v2Query.prefix];
|
||||||
|
} else if (v2Query.collection) {
|
||||||
|
params.prefixes = [v2Query.collection];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category
|
||||||
|
if (v2Query.category) {
|
||||||
|
params.category = v2Query.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable partial
|
||||||
|
if (v2Query.similar) {
|
||||||
|
const similar = paramToBoolean(v2Query.similar);
|
||||||
|
if (typeof similar === 'boolean') {
|
||||||
|
params.partial = similar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run query
|
||||||
|
const searchResults = search(params, searchIndexData, iconSets);
|
||||||
|
if (!searchResults) {
|
||||||
|
res.send(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate result
|
||||||
|
const response: APIv2SearchResponse = {
|
||||||
|
icons: searchResults.names.slice(start),
|
||||||
|
total: searchResults.names.length,
|
||||||
|
limit: params.limit,
|
||||||
|
start,
|
||||||
|
collections: Object.create(null),
|
||||||
|
request: v2Query,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add icon sets
|
||||||
|
for (let i = 0; i < searchResults.prefixes.length; i++) {
|
||||||
|
const prefix = searchResults.prefixes[i];
|
||||||
|
const info = iconSets[prefix]?.item.info;
|
||||||
|
if (info) {
|
||||||
|
response.collections[prefix] = info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJSONResponse(response, q, wrap, res);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Convert string to boolean
|
||||||
|
*/
|
||||||
|
export function paramToBoolean(value: string, defaultValue?: boolean): boolean | undefined {
|
||||||
|
switch (value) {
|
||||||
|
case 'true':
|
||||||
|
case 'yes':
|
||||||
|
case '1':
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'false':
|
||||||
|
case 'no':
|
||||||
|
case '0':
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,9 @@ export interface IconSetIconsListIcons {
|
||||||
|
|
||||||
// Characters, key = character, value = icon
|
// Characters, key = character, value = icon
|
||||||
chars?: Record<string, IconSetIconNames>;
|
chars?: Record<string, IconSetIconNames>;
|
||||||
|
|
||||||
|
// Keywords, set if search engine is enabled
|
||||||
|
keywords?: Record<string, Set<IconSetIconNames>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,6 @@ export interface StoredIconSet {
|
||||||
|
|
||||||
// Themes
|
// Themes
|
||||||
themes?: StorageIconSetThemes;
|
themes?: StorageIconSetThemes;
|
||||||
|
|
||||||
// TODO: add properties for search data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
/**
|
||||||
|
* List of keywords that can be used to autocomplete keyword
|
||||||
|
*/
|
||||||
|
export type PartialKeywords = Readonly<string[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search data
|
||||||
|
*/
|
||||||
|
export interface SearchIndexData {
|
||||||
|
// List of searchable prefixes
|
||||||
|
sortedPrefixes: string[];
|
||||||
|
|
||||||
|
// List of keywords, value is set of prefixes where keyword is used
|
||||||
|
// Prefixes are added in set in same order as in `sortedPrefixes`
|
||||||
|
keywords: Record<string, Set<string>>;
|
||||||
|
|
||||||
|
// 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>;
|
||||||
|
|
||||||
|
// Last cleanup for old partial lists
|
||||||
|
partialCleanup: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search parameters
|
||||||
|
*/
|
||||||
|
export interface SearchParams {
|
||||||
|
// List of prefixes to search
|
||||||
|
prefixes?: string[];
|
||||||
|
|
||||||
|
// Icon set category
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
// Icon set tag
|
||||||
|
tag?: string;
|
||||||
|
|
||||||
|
// Filter icon sets by palette
|
||||||
|
palette?: boolean;
|
||||||
|
|
||||||
|
// Keyword
|
||||||
|
keyword: string;
|
||||||
|
|
||||||
|
// Search results limit
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
// Toggle partial matches
|
||||||
|
partial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of matches
|
||||||
|
*/
|
||||||
|
export interface SearchKeywordsEntry {
|
||||||
|
// List of prefixes, extracted from search query
|
||||||
|
prefixes?: string[];
|
||||||
|
|
||||||
|
// List of keywords icon should match
|
||||||
|
keywords: string[];
|
||||||
|
|
||||||
|
// Strings to test icon value
|
||||||
|
test?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches
|
||||||
|
*/
|
||||||
|
export interface SearchKeywords {
|
||||||
|
// List of searches
|
||||||
|
searches: SearchKeywordsEntry[];
|
||||||
|
|
||||||
|
// Partial keyword, used in all matches
|
||||||
|
partial?: string;
|
||||||
|
|
||||||
|
// Params extracted from keywords
|
||||||
|
params: Partial<SearchParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search results
|
||||||
|
*/
|
||||||
|
export interface SearchResultsData {
|
||||||
|
// Prefixes
|
||||||
|
prefixes: string[];
|
||||||
|
|
||||||
|
// Icon names
|
||||||
|
names: string[];
|
||||||
|
|
||||||
|
// True if has more results
|
||||||
|
hasMore?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -136,6 +136,6 @@ export interface APIv2SearchResponse {
|
||||||
// Info about icon sets
|
// Info about icon sets
|
||||||
collections: Record<string, IconifyInfo>;
|
collections: Record<string, IconifyInfo>;
|
||||||
|
|
||||||
// Copy of request
|
// Copy of request, values are string
|
||||||
request: APIv2SearchParams;
|
request: Record<keyof APIv2SearchParams, string>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { DirectoryDownloader } from '../../lib/downloaders/directory';
|
||||||
|
import { createHardcodedCollectionsListImporter } from '../../lib/importers/collections/list';
|
||||||
|
import { createJSONIconSetImporter } from '../../lib/importers/icon-set/json';
|
||||||
|
import { updateSearchIndex } from '../../lib/data/search';
|
||||||
|
import { getPartialKeywords } from '../../lib/data/search/partial';
|
||||||
|
import { filterSearchPrefixes } from '../../lib/data/search/prefixes';
|
||||||
|
import type { IconSetImportedData } from '../../lib/types/importers/common';
|
||||||
|
import type { IconSetEntry } from '../../lib/types/importers';
|
||||||
|
import type { SearchParams } from '../../lib/types/search';
|
||||||
|
|
||||||
|
describe('Creating search index, checking prefixes', () => {
|
||||||
|
test('One icon set', async () => {
|
||||||
|
// Create importer
|
||||||
|
const importer = createHardcodedCollectionsListImporter(['mdi-light'], (prefix) =>
|
||||||
|
createJSONIconSetImporter(new DirectoryDownloader<IconSetImportedData>(`tests/fixtures/json`), {
|
||||||
|
prefix,
|
||||||
|
filename: `/${prefix}.json`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await importer.init();
|
||||||
|
const data = importer.data!;
|
||||||
|
|
||||||
|
// Get keywords for mdi-light
|
||||||
|
const mdiLightData = data.iconSets['mdi-light']!;
|
||||||
|
const mdiLightKeywords = mdiLightData.icons.keywords!;
|
||||||
|
expect(mdiLightKeywords).toBeTruthy();
|
||||||
|
|
||||||
|
const accountKeyword = mdiLightKeywords['account']!;
|
||||||
|
expect(accountKeyword).toBeTruthy();
|
||||||
|
expect(accountKeyword.size).toBe(2);
|
||||||
|
|
||||||
|
const xmlKeyword = mdiLightKeywords['xml']!;
|
||||||
|
expect(xmlKeyword).toBeTruthy();
|
||||||
|
expect(xmlKeyword.size).toBe(1);
|
||||||
|
|
||||||
|
// Create search index
|
||||||
|
const prefixes = data.prefixes;
|
||||||
|
expect(prefixes).toEqual(['mdi-light']);
|
||||||
|
const searchIndex = updateSearchIndex(prefixes, {
|
||||||
|
'mdi-light': {
|
||||||
|
importer,
|
||||||
|
item: mdiLightData,
|
||||||
|
},
|
||||||
|
})!;
|
||||||
|
|
||||||
|
// Check index
|
||||||
|
expect(searchIndex).toBeTruthy();
|
||||||
|
expect(searchIndex!.sortedPrefixes).toEqual(['mdi-light']);
|
||||||
|
expect(Object.keys(searchIndex.keywords)).toEqual(Object.keys(mdiLightKeywords));
|
||||||
|
|
||||||
|
expect(searchIndex.keywords['account']).toEqual(new Set(['mdi-light']));
|
||||||
|
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']);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
test('Two icon sets', async () => {
|
||||||
|
// Create importer
|
||||||
|
// Use 'mdi-test-prefix' instead of 'mdi' to test prefix filters
|
||||||
|
const importer = createHardcodedCollectionsListImporter(['mdi-light', 'mdi-test-prefix'], (prefix) =>
|
||||||
|
createJSONIconSetImporter(new DirectoryDownloader<IconSetImportedData>(`tests/fixtures/json`), {
|
||||||
|
prefix,
|
||||||
|
filename: prefix === 'mdi-test-prefix' ? '/mdi.json' : `/${prefix}.json`,
|
||||||
|
ignoreInvalidPrefix: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await importer.init();
|
||||||
|
const data = importer.data!;
|
||||||
|
|
||||||
|
// Get keywords
|
||||||
|
const mdiData = data.iconSets['mdi-test-prefix']!;
|
||||||
|
const mdiKeywords = mdiData.icons.keywords!;
|
||||||
|
expect(mdiKeywords).toBeTruthy();
|
||||||
|
|
||||||
|
const mdiLightData = data.iconSets['mdi-light']!;
|
||||||
|
const mdiLightKeywords = mdiLightData.icons.keywords!;
|
||||||
|
expect(mdiLightKeywords).toBeTruthy();
|
||||||
|
|
||||||
|
// Create search index
|
||||||
|
const prefixes = data.prefixes;
|
||||||
|
expect(prefixes).toEqual(['mdi-light', 'mdi-test-prefix']);
|
||||||
|
const iconSets: Record<string, IconSetEntry> = {
|
||||||
|
'mdi-light': {
|
||||||
|
importer,
|
||||||
|
item: mdiLightData,
|
||||||
|
},
|
||||||
|
'mdi-test-prefix': {
|
||||||
|
importer,
|
||||||
|
item: mdiData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const searchIndex = updateSearchIndex(prefixes, iconSets)!;
|
||||||
|
|
||||||
|
// Check index
|
||||||
|
expect(searchIndex).toBeTruthy();
|
||||||
|
expect(searchIndex!.sortedPrefixes).toEqual(['mdi-light', 'mdi-test-prefix']);
|
||||||
|
|
||||||
|
expect(Object.keys(searchIndex.keywords)).not.toEqual(Object.keys(mdiLightKeywords));
|
||||||
|
expect(Object.keys(searchIndex.keywords)).not.toEqual(Object.keys(mdiKeywords));
|
||||||
|
expect(new Set(Object.keys(searchIndex.keywords))).toEqual(
|
||||||
|
new Set([...Object.keys(mdiKeywords), ...Object.keys(mdiLightKeywords)])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(searchIndex.keywords['account']).toEqual(new Set(['mdi-light', 'mdi-test-prefix']));
|
||||||
|
expect(searchIndex.keywords['xml']).toEqual(new Set(['mdi-light', 'mdi-test-prefix']));
|
||||||
|
expect(searchIndex.keywords['alphabetical']).toEqual(new Set(['mdi-test-prefix']));
|
||||||
|
|
||||||
|
// Test filter
|
||||||
|
const baseParams: SearchParams = {
|
||||||
|
keyword: '',
|
||||||
|
limit: 0,
|
||||||
|
};
|
||||||
|
expect(filterSearchPrefixes(searchIndex, iconSets, baseParams)).toEqual(['mdi-light', 'mdi-test-prefix']);
|
||||||
|
|
||||||
|
// Test filter by prefixes
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
prefixes: ['mdi-light', 'whatever'],
|
||||||
|
})
|
||||||
|
).toEqual(['mdi-light']);
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
prefixes: ['mdi-'],
|
||||||
|
})
|
||||||
|
).toEqual(['mdi-light', 'mdi-test-prefix']);
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
prefixes: ['mdi', 'mdi-test-'],
|
||||||
|
})
|
||||||
|
).toEqual(['mdi-test-prefix']);
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
prefixes: ['material'],
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
// Add palette
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
palette: false,
|
||||||
|
})
|
||||||
|
).toEqual(['mdi-light', 'mdi-test-prefix']);
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
prefixes: ['mdi-test-prefix'],
|
||||||
|
palette: false,
|
||||||
|
})
|
||||||
|
).toEqual(['mdi-test-prefix']);
|
||||||
|
expect(
|
||||||
|
filterSearchPrefixes(searchIndex, iconSets, {
|
||||||
|
...baseParams,
|
||||||
|
palette: true,
|
||||||
|
})
|
||||||
|
).toEqual([]);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { DirectoryDownloader } from '../../lib/downloaders/directory';
|
||||||
|
import { createHardcodedCollectionsListImporter } from '../../lib/importers/collections/list';
|
||||||
|
import { createJSONIconSetImporter } from '../../lib/importers/icon-set/json';
|
||||||
|
import { updateSearchIndex } from '../../lib/data/search';
|
||||||
|
import { search } from '../../lib/data/search/index';
|
||||||
|
import type { IconSetImportedData } from '../../lib/types/importers/common';
|
||||||
|
import type { IconSetEntry } from '../../lib/types/importers';
|
||||||
|
|
||||||
|
describe('Searching icons', () => {
|
||||||
|
test('Multiple icon sets', async () => {
|
||||||
|
// Create importer
|
||||||
|
const importer = createHardcodedCollectionsListImporter(
|
||||||
|
['mdi-light', 'mdi-test-prefix', 'emojione-v1'],
|
||||||
|
(prefix) => {
|
||||||
|
let filename: string;
|
||||||
|
switch (prefix) {
|
||||||
|
case 'mdi-test-prefix':
|
||||||
|
filename = '/json/mdi.json';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'mdi':
|
||||||
|
case 'mdi-light':
|
||||||
|
filename = `/json/${prefix}.json`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
filename = `/${prefix}.json`;
|
||||||
|
}
|
||||||
|
return createJSONIconSetImporter(new DirectoryDownloader<IconSetImportedData>(`tests/fixtures`), {
|
||||||
|
prefix,
|
||||||
|
filename,
|
||||||
|
ignoreInvalidPrefix: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await importer.init();
|
||||||
|
const data = importer.data!;
|
||||||
|
|
||||||
|
// Get keywords
|
||||||
|
const mdiData = data.iconSets['mdi-test-prefix']!;
|
||||||
|
const mdiKeywords = mdiData.icons.keywords!;
|
||||||
|
expect(mdiKeywords).toBeTruthy();
|
||||||
|
|
||||||
|
const mdiLightData = data.iconSets['mdi-light']!;
|
||||||
|
const mdiLightKeywords = mdiLightData.icons.keywords!;
|
||||||
|
expect(mdiLightKeywords).toBeTruthy();
|
||||||
|
|
||||||
|
// Create search index
|
||||||
|
const prefixes = data.prefixes;
|
||||||
|
expect(prefixes).toEqual(['mdi-light', 'mdi-test-prefix', 'emojione-v1']);
|
||||||
|
|
||||||
|
const iconSets: Record<string, IconSetEntry> = {};
|
||||||
|
prefixes.forEach((prefix) => {
|
||||||
|
iconSets[prefix] = {
|
||||||
|
importer,
|
||||||
|
item: data.iconSets[prefix]!,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const searchIndex = updateSearchIndex(prefixes, iconSets)!;
|
||||||
|
|
||||||
|
// Check index
|
||||||
|
expect(searchIndex).toBeTruthy();
|
||||||
|
expect(searchIndex!.sortedPrefixes).toEqual(['mdi-light', 'mdi-test-prefix', 'emojione-v1']);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
expect(
|
||||||
|
search(
|
||||||
|
{
|
||||||
|
keyword: 'cycle',
|
||||||
|
limit: 999,
|
||||||
|
},
|
||||||
|
searchIndex,
|
||||||
|
iconSets
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
prefixes: ['mdi-test-prefix', 'emojione-v1'],
|
||||||
|
names: [
|
||||||
|
'mdi-test-prefix:cash-cycle',
|
||||||
|
'mdi-test-prefix:hand-cycle',
|
||||||
|
'mdi-test-prefix:power-cycle',
|
||||||
|
'mdi-test-prefix:bicycle',
|
||||||
|
'mdi-test-prefix:bicycle-basket',
|
||||||
|
'mdi-test-prefix:bicycle-cargo',
|
||||||
|
'mdi-test-prefix:bicycle-electric',
|
||||||
|
'mdi-test-prefix:bicycle-penny-farthing',
|
||||||
|
'emojione-v1:bicycle',
|
||||||
|
'mdi-test-prefix:battery-recycle',
|
||||||
|
'mdi-test-prefix:battery-recycle-outline',
|
||||||
|
'mdi-test-prefix:recycle',
|
||||||
|
'mdi-test-prefix:recycle-variant',
|
||||||
|
'mdi-test-prefix:water-recycle',
|
||||||
|
'mdi-test-prefix:unicycle',
|
||||||
|
'mdi-test-prefix:motorcycle',
|
||||||
|
'mdi-test-prefix:motorcycle-electric',
|
||||||
|
'mdi-test-prefix:motorcycle-off',
|
||||||
|
'emojione-v1:motorcycle',
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,313 @@
|
||||||
|
import { splitKeywordEntries } from '../../lib/data/search/split';
|
||||||
|
|
||||||
|
describe('Splitting keywords', () => {
|
||||||
|
test('Bad entries', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['home?'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['bad_stuff'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries([], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi', ''], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Simple entry', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['home'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Multiple simple entries', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi', 'home'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi', 'home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi', 'home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Incomplete prefix', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-', 'home'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
test: ['mdi-'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-', 'home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi-',
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
test: ['mdi-'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-', 'home'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi-',
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi'],
|
||||||
|
test: ['mdi-'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Long entry', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-home-outline'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home', 'outline'],
|
||||||
|
test: ['mdi-home-outline'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-home-outline'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['home', 'outline'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home', 'outline'],
|
||||||
|
test: ['mdi-home-outline'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-home-outline'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['home'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
test: ['mdi-home-outline'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'outline',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Complex entries', () => {
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-light', 'arrow-left'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'arrow', 'left'],
|
||||||
|
test: ['mdi-light', 'arrow-left'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-light', 'arrow-left'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: false,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: ['arrow', 'left'],
|
||||||
|
test: ['arrow-left'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light', 'arrow', 'left'],
|
||||||
|
test: ['arrow-left'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'arrow', 'left'],
|
||||||
|
test: ['mdi-light', 'arrow-left'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-light', 'arrow-left'], {
|
||||||
|
prefix: false,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'arrow'],
|
||||||
|
test: ['mdi-light', 'arrow-left'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitKeywordEntries(['mdi-light', 'arrow-left'], {
|
||||||
|
prefix: true,
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: ['arrow'],
|
||||||
|
test: ['arrow-left'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light', 'arrow'],
|
||||||
|
test: ['arrow-left'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'arrow'],
|
||||||
|
test: ['mdi-light', 'arrow-left'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'left',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
import { splitKeyword } from '../../lib/data/search/split';
|
||||||
|
|
||||||
|
describe('Splitting keywords', () => {
|
||||||
|
test('Bad entries', () => {
|
||||||
|
expect(splitKeyword('')).toBeUndefined();
|
||||||
|
expect(splitKeyword('-')).toBeUndefined();
|
||||||
|
expect(splitKeyword('prefix:mdi')).toBeUndefined();
|
||||||
|
expect(splitKeyword('palette=true')).toBeUndefined();
|
||||||
|
expect(splitKeyword('bad,entry')).toBeUndefined();
|
||||||
|
|
||||||
|
// Too many prefix entries
|
||||||
|
expect(splitKeyword('mdi:home mdi-light:home')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Prefixes', () => {
|
||||||
|
// 'mdi-home'
|
||||||
|
expect(splitKeyword('mdi-home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi', // leftover from internal function
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi'],
|
||||||
|
test: ['mdi-home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('mdi-home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi', // leftover from internal function
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'home'],
|
||||||
|
test: ['mdi-home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'mdi:home'
|
||||||
|
expect(splitKeyword('mdi:home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('mdi:home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'prefix:mdi home'
|
||||||
|
expect(splitKeyword('prefix:mdi home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('prefix:mdi home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'prefix=mdi home'
|
||||||
|
expect(splitKeyword('prefix=mdi home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('prefix=mdi home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'prefixes:mdi home'
|
||||||
|
expect(splitKeyword('prefixes:mdi home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('prefixes:mdi home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'prefixes:fa6-,mdi- home'
|
||||||
|
expect(splitKeyword('prefixes:fa6-,mdi- home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['fa6-', 'mdi-'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('prefixes:fa6-,mdi- home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['fa6-', 'mdi-'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'prefixes=mdi* home'
|
||||||
|
expect(splitKeyword('prefixes=mdi* home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi', 'mdi-'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('prefixes=mdi* home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi', 'mdi-'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'mdi-light home'
|
||||||
|
expect(splitKeyword('mdi-light home')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi-light'],
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light'],
|
||||||
|
test: ['mdi-light'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('mdi-light home', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi-light'],
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light', 'home'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'home'],
|
||||||
|
test: ['mdi-light'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 'mdi-light home-outline'
|
||||||
|
expect(splitKeyword('mdi-light home-outline')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi-light'],
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: ['home'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light', 'home'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'home'],
|
||||||
|
test: ['mdi-light', 'home-outline'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'outline',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(splitKeyword('mdi-light home-outline', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi-light'],
|
||||||
|
prefix: 'mdi-light',
|
||||||
|
keywords: ['home', 'outline'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
prefix: 'mdi',
|
||||||
|
keywords: ['light', 'home', 'outline'],
|
||||||
|
test: ['home-outline'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['mdi', 'light', 'home', 'outline'],
|
||||||
|
test: ['mdi-light', 'home-outline'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Keywords', () => {
|
||||||
|
expect(splitKeyword('home palette:true')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {
|
||||||
|
palette: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitKeyword('home palette=0')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {
|
||||||
|
palette: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitKeyword('home prefixes=mdi*,fa6-')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi', 'mdi-', 'fa6-'],
|
||||||
|
keywords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
partial: 'home',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitKeyword('home prefix=mdi palette=1', false)).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
prefixes: ['mdi'],
|
||||||
|
keywords: ['home'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {
|
||||||
|
palette: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Too short for partial
|
||||||
|
expect(splitKeyword('ab')).toEqual({
|
||||||
|
searches: [
|
||||||
|
{
|
||||||
|
keywords: ['ab'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue