feat: support icons list query from API v2

This commit is contained in:
Vjacheslav Trushkin 2022-10-15 18:44:16 +03:00
parent 5c53b0a01a
commit a67e37fec1
13 changed files with 594 additions and 48 deletions

View File

@ -0,0 +1,71 @@
import type { IconifyJSON } from '@iconify/types';
import type { IconSetAPIv2IconsList, IconSetIconsListIcons } from '../../../types/icon-set/extra';
type ThemeKey = 'themes' | 'prefixes' | 'suffixes';
const themeKeys: ThemeKey[] = ['themes', 'prefixes', 'suffixes'];
/**
* Prepare data for icons list API v2 response
*/
export function prepareAPIv2IconsList(iconSet: IconifyJSON, iconsList: IconSetIconsListIcons): IconSetAPIv2IconsList {
// Prepare data
const rendered: IconSetAPIv2IconsList['rendered'] = {
prefix: iconSet.prefix,
total: iconsList.visible.size,
};
const info = iconSet.info;
if (info) {
rendered.title = info.name;
rendered.info = info;
}
if (iconsList.uncategorised.length) {
rendered.uncategorized = iconsList.uncategorised;
}
// Convert categories
if (iconsList.tags.length) {
const categories = (rendered.categories = Object.create(null) as Record<string, string[]>);
for (let i = 0; i < iconsList.tags.length; i++) {
const tag = iconsList.tags[i];
categories[tag.title] = tag.icons;
}
}
// Hidden icons
const hidden = Array.from(iconsList.hidden).concat(Object.keys(iconsList.hiddenAliases));
if (hidden.length) {
rendered.hidden = hidden;
}
// Add aliases
const aliases = {
...iconsList.visibleAliases,
...iconsList.hiddenAliases,
};
for (const alias in aliases) {
rendered.aliases = aliases;
break;
}
// Themes
for (let i = 0; i < themeKeys.length; i++) {
const key = themeKeys[i] as ThemeKey;
if (iconSet[key]) {
rendered[key as 'themes'] = iconSet[key as 'themes'];
}
}
// Result
const result: IconSetAPIv2IconsList = {
rendered,
};
// Add characters
if (iconSet.chars) {
result.chars = iconSet.chars;
}
return result;
}

View File

@ -0,0 +1,156 @@
import type { IconifyAliases, IconifyJSON, IconifyOptional } from '@iconify/types';
import { defaultIconProps } from '@iconify/utils/lib/icon/defaults';
import type { IconSetIconsListIcons, IconSetIconsListTag } from '../../../types/icon-set/extra';
const customisableProps = Object.keys(defaultIconProps) as (keyof IconifyOptional)[];
/**
* Generate icons tree
*/
export function generateIconSetIconsTree(iconSet: IconifyJSON): IconSetIconsListIcons {
const iconSetIcons = iconSet.icons;
const iconSetAliases = iconSet.aliases || (Object.create(null) as IconifyAliases);
const checked: Set<string> = new Set();
const visible: Set<string> = new Set();
const hidden: Set<string> = new Set();
const failed: Set<string> = new Set();
const visibleAliases = Object.create(null) as Record<string, string>;
const hiddenAliases = Object.create(null) as Record<string, string>;
// Generate list of tags for each icon
const tags: IconSetIconsListTag[] = [];
const uncategorised: string[] = [];
const resolvedTags = Object.create(null) as Record<string, Set<IconSetIconsListTag>>;
const categories = iconSet.categories;
if (categories) {
for (const title in categories) {
const items = categories[title];
if (items instanceof Array) {
const tag: IconSetIconsListTag = {
title,
icons: [],
};
tags.push(tag);
for (let i = 0; i < items.length; i++) {
const name = items[i];
(resolvedTags[name] || (resolvedTags[name] = new Set())).add(tag);
}
}
}
}
// Parse all icons
for (const name in iconSetIcons) {
const isVisible = !iconSetIcons[name].hidden;
(isVisible ? visible : hidden).add(name);
checked.add(name);
if (isVisible) {
// Check tag
const iconTags = resolvedTags[name];
if (iconTags) {
// Add icon to each tag
iconTags.forEach((tag) => {
tag.icons.push(name);
});
} else {
// No tags: uncategorised
uncategorised.push(name);
}
}
}
// Parse all aliases
const resolve = (name: string) => {
if (checked.has(name)) {
// Already checked
return;
}
checked.add(name);
// Mark as failed to avoid loop, will be removed later on success
failed.add(name);
const item = iconSetAliases[name];
if (!item) {
// Failed
return;
}
// Get parent
const parent = item.parent;
if (!checked.has(parent)) {
resolve(parent);
}
// Get parent
if (failed.has(parent)) {
return;
}
// Check if item has transformations
let transformed = false;
for (let i = 0; i < customisableProps.length; i++) {
if (item[customisableProps[i]] !== void 0) {
transformed = true;
break;
}
}
// Success
const isVisible =
item.hidden === false || (!item.hidden && (visible.has(parent) || visibleAliases[parent] !== void 0));
failed.delete(name);
// Add tags
let itemTags: Set<IconSetIconsListTag> | undefined = resolvedTags[name];
if (!itemTags) {
// Use tags from parent icon
itemTags = resolvedTags[name] = resolvedTags[parent];
}
if (isVisible && transformed) {
// Icon: add to tags
if (itemTags) {
itemTags.forEach((tag) => {
tag.icons.push(name);
});
} else {
uncategorised.push(name);
}
}
// Add icon
if (transformed) {
// Treat as new icon
(isVisible ? visible : hidden).add(name);
} else {
// Treat as alias
const parentName = visibleAliases[parent] || hiddenAliases[parent] || parent;
(isVisible ? visibleAliases : hiddenAliases)[name] = parentName;
}
};
for (const name in iconSetAliases) {
resolve(name);
}
// Sort icons in tags
for (let i = 0; i < tags.length; i++) {
tags[i].icons.sort((a, b) => a.localeCompare(b));
}
uncategorised.sort((a, b) => a.localeCompare(b));
// Return data
return {
visible,
hidden,
visibleAliases,
hiddenAliases,
failed,
tags: tags.filter((tag) => tag.icons.length > 0),
uncategorised,
};
}

View File

@ -0,0 +1,15 @@
import type { IconifyJSON } from '@iconify/types';
import type { IconSetIconsListIcons } from '../../../types/icon-set/extra';
/**
* Removes bad aliases
*/
export function removeBadAliases(data: IconifyJSON, iconsList: IconSetIconsListIcons) {
const aliases = data.aliases;
if (!aliases) {
return;
}
iconsList.failed.forEach((name) => {
delete aliases[name];
});
}

View File

@ -7,6 +7,9 @@ import type { MemoryStorage, MemoryStorageItem } from '../../../types/storage';
import { createSplitRecordsTree, splitRecords } from '../../storage/split';
import { createStorage, createStoredItem } from '../../storage/create';
import { getIconSetSplitChunksCount, splitIconSetMainData } from './split';
import { generateIconSetIconsTree } from '../lists/icons';
import { removeBadAliases } from '../lists/validate';
import { prepareAPIv2IconsList } from '../lists/icons-v2';
/**
* Storage
@ -28,6 +31,15 @@ export function storeLoadedIconSet(
storage: MemoryStorage<IconifyIcons> = iconSetsStorage,
config: SplitIconSetConfig = splitIconSetConfig
) {
// Get icons list and remove bad aliases
const icons = generateIconSetIconsTree(iconSet);
removeBadAliases(iconSet, icons);
// Fix icons counter
if (iconSet.info) {
iconSet.info.total = icons.visible.size;
}
// Get common items
const common = splitIconSetMainData(iconSet);
@ -66,6 +78,8 @@ export function storeLoadedIconSet(
storage,
items: storedItems,
tree,
icons,
apiV2IconsCache: prepareAPIv2IconsList(iconSet, icons),
};
if (iconSet.info) {
result.info = iconSet.info;

View File

@ -1,43 +0,0 @@
import type { IconifyJSON } from '@iconify/types';
/**
* Removes bad aliases
*/
export function removeBadAliases(data: IconifyJSON) {
const icons = data.icons;
const aliases = data.aliases || {};
const tested: Set<string> = new Set();
const failed: Set<string> = new Set();
function resolve(name: string): boolean {
if (icons[name]) {
return true;
}
if (!tested.has(name)) {
// Temporary mark as failed if parent alias points to this alias to avoid infinite loop
tested.add(name);
failed.add(name);
// Get parent icon name and resolve it
const parent = aliases[name]?.parent;
if (parent && resolve(parent)) {
failed.delete(name);
}
}
return !failed.has(name);
}
// Resolve aliases
const keys = Object.keys(aliases);
for (let i = 0; i < keys.length; i++) {
resolve(keys[i]);
}
// Remove failed aliases
failed.forEach((name) => {
delete aliases[name];
});
}

View File

@ -13,7 +13,7 @@ export function getIconsToRetrieve(
copyTo?: IconifyAliases
): Set<string> {
const icons: Set<string> = new Set();
const aliases = iconSetData.aliases || {};
const aliases = iconSetData.aliases || (Object.create(null) as IconifyAliases);
function resolve(name: string) {
if (!aliases[name]) {
@ -102,7 +102,7 @@ export function getStoredIconsData(iconSet: StoredIconSet, names: string[], call
// Nothing to retrieve
callback({
...iconSet.common,
icons: {},
icons: Object.create(null),
aliases,
not_found: names,
});

View File

@ -2,6 +2,7 @@ import fastify, { FastifyReply } from 'fastify';
import { appConfig } from '../config/app';
import { runWhenLoaded } from '../data/loading';
import { iconNameRoutePartialRegEx, iconNameRouteRegEx, splitIconName } from '../misc/name';
import { generateAPIv2CollectionResponse } from './responses/collection-v2';
import { generateCollectionsListResponse } from './responses/collections';
import { generateIconsDataResponse } from './responses/icons';
import { generateLastModifiedResponse } from './responses/modified';
@ -114,6 +115,13 @@ export async function startHTTPServer() {
});
});
// Icons list, API v2
server.get('/collection', (req, res) => {
runWhenLoaded(() => {
generateAPIv2CollectionResponse(req.query, res);
});
});
// Update icon sets
server.get('/update', (req, res) => {
generateUpdateResponse(req.query, res);
@ -148,6 +156,7 @@ export async function startHTTPServer() {
// Error handling
server.setDefaultRoute((req, res) => {
res.statusCode = 301;
console.log('404:', req.url);
res.setHeader('Location', appConfig.redirectIndex);
// Need to set custom headers because hooks don't work here

View File

@ -0,0 +1,51 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateIconSetIconsTree } from '../../data/icon-set/lists/icons';
import { iconSets } from '../../data/icon-sets';
import type { APIv2CollectionResponse } from '../../types/server/v2';
import { checkJSONPQuery, sendJSONResponse } from '../helpers/json';
/**
* Send API v2 response
*
* This response ignores the following parameters:
* - `aliases` -> always enabled
* - `hidden` -> always enabled
*
* Those parameters are always requested anyway, so does not make sense to re-create data in case they are disabled
*/
export function generateAPIv2CollectionResponse(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;
}
// Get icon set
const prefix = q.prefix;
if (!prefix || !iconSets[prefix]) {
res.send(404);
return;
}
const iconSet = iconSets[prefix].item;
const apiV2IconsCache = iconSet.apiV2IconsCache;
// Filter prefixes
const response: APIv2CollectionResponse = {
...apiV2IconsCache.rendered,
};
if (!q.info) {
// Delete info
delete response.info;
}
if (q.chars && apiV2IconsCache.chars) {
// Add characters map
response.chars = apiV2IconsCache.chars;
}
sendJSONResponse(response, q, wrap, res);
}

View File

@ -6,6 +6,11 @@ import { filterPrefixesByPrefix } from '../helpers/prefixes';
/**
* Send response
*
* Request and responses are the same for v2 and v3
*
* Ignored parameters:
* - hidden (always enabled)
*/
export function generateCollectionsListResponse(query: FastifyRequest['query'], res: FastifyReply) {
const q = (query || {}) as Record<string, string>;

View File

@ -0,0 +1,81 @@
import type { IconifyInfo, IconifyJSON } from '@iconify/types';
/**
* Tag
*/
export interface IconSetIconsListTag {
// Title
title: string;
// Names of icons
icons: string[];
}
/**
* Icons
*/
export interface IconSetIconsListIcons {
// Visible icons
visible: Set<string>;
// Hidden icons
hidden: Set<string>;
// Aliases, pointing to parent icon in either `visible` or `hidden` set
visibleAliases: Record<string, string>;
hiddenAliases: Record<string, string>;
// Failed aliases
failed: Set<string>;
// Tags
tags: IconSetIconsListTag[];
uncategorised: string[];
}
/**
* Prepared icons list for API v2 response
*/
export interface IconSetAPIv2IconsList {
// Prepared data
rendered: {
// Icon set prefix
prefix: string;
// Number of icons (duplicate of info?.total)
total: number;
// Icon set title, if available (duplicate of info?.name)
title?: string;
// Icon set info
info?: IconifyInfo;
// List of icons without categories
uncategorized?: string[];
// List of icons, sorted by category
categories?: Record<string, string[]>;
// List of hidden icons
hidden?: string[];
// List of aliases, key = alias, value = parent icon
aliases?: Record<string, string>;
// Themes
themes?: IconifyJSON['themes'];
prefixes?: IconifyJSON['prefixes'];
suffixes?: IconifyJSON['suffixes'];
};
// Characters, key = character, value = icon name
chars?: Record<string, string>;
}
/**
* Extra data generated for each icon set
*/
export interface IconSetExtraData {
v2list: IconSetAPIv2IconsList;
}

View File

@ -1,6 +1,7 @@
import type { IconifyIcons, IconifyInfo, IconifyJSON } from '@iconify/types';
import type { SplitDataTree, SplitRecord } from '../split';
import type { IconifyIcons, IconifyInfo } from '@iconify/types';
import type { SplitDataTree } from '../split';
import type { MemoryStorage, MemoryStorageItem } from '../storage';
import type { IconSetAPIv2IconsList, IconSetIconsListIcons } from './extra';
import type { SplitIconifyJSONMainData } from './split';
/**
@ -20,6 +21,10 @@ export interface StoredIconSet {
items: MemoryStorageItem<IconifyIcons>[];
tree: SplitDataTree<MemoryStorageItem<IconifyIcons>>;
// Icons list
icons: IconSetIconsListIcons;
apiV2IconsCache: IconSetAPIv2IconsList;
// TODO: add properties for search data
}

181
tests/icon-set/tree-test.ts Normal file
View File

@ -0,0 +1,181 @@
import { generateIconSetIconsTree } from '../../lib/data/icon-set/lists/icons';
describe('Icons tree', () => {
test('Simple icon set', () => {
const tree = generateIconSetIconsTree({
prefix: 'foo',
icons: {
bar: {
body: '<g id="bar" />',
},
baz: {
body: '<g id="baz" />',
},
foo: {
body: '<g id="foo" />',
},
},
});
expect(tree.failed).toEqual(new Set());
expect(tree.visible).toEqual(new Set(['bar', 'baz', 'foo']));
expect(tree.hidden).toEqual(new Set());
expect(tree.visibleAliases).toEqual({});
expect(tree.hiddenAliases).toEqual({});
expect(tree.tags).toEqual([]);
expect(tree.uncategorised).toEqual(['bar', 'baz', 'foo']);
});
test('Few aliases', () => {
const tree = generateIconSetIconsTree({
prefix: 'foo',
icons: {
bar: {
body: '<g />',
},
bar2: {
body: '<g />',
},
},
aliases: {
'foo': {
parent: 'bar',
hFlip: true,
},
'foo2': {
parent: 'foo',
},
'missing-alias': {
parent: 'missing-icon',
},
},
categories: {
Bar: ['bar', 'baz'],
},
});
expect(tree.failed).toEqual(new Set(['missing-alias', 'missing-icon']));
expect(tree.visible).toEqual(new Set(['bar', 'bar2', 'foo']));
expect(tree.hidden).toEqual(new Set());
expect(tree.visibleAliases).toEqual({ foo2: 'foo' });
expect(tree.hiddenAliases).toEqual({});
expect(tree.tags).toEqual([
{
title: 'Bar',
icons: ['bar', 'foo'],
},
]);
expect(tree.uncategorised).toEqual(['bar2']);
});
test('Many aliases', () => {
const tree = generateIconSetIconsTree({
prefix: 'foo',
icons: {
icon1: {
body: '<path d="icon1" />',
width: 20,
height: 20,
},
icon2: {
body: '<path d="icon2" />',
width: 24,
rotate: 1,
hFlip: true,
hidden: true,
},
},
aliases: {
alias2a: {
// Alias before parent
parent: 'alias2f',
width: 20,
height: 20,
},
alias2f: {
parent: 'icon2',
width: 22,
rotate: 1,
hFlip: true,
vFlip: true,
},
alias2z: {
// Alias after parent
parent: 'alias2f',
width: 21,
rotate: 3,
// Visible, but parent is hidden
hidden: false,
},
alias2z3: {
// 3 parents: alias2z, alias2f, icon2
parent: 'alias2z',
},
alias2z4: {
// 4 parents: alias2z3, alias2z, alias2f, icon2
parent: 'alias2z3',
},
alias2z5: {
// 5 parents: alias2z4, alias2z3, alias2z, alias2f, icon2
parent: 'alias2z4',
},
alias2z6: {
// 6 parents: alias2z5, alias2z4, alias2z3, alias2z, alias2f, icon2
parent: 'alias2z5',
hidden: true,
},
alias2z7: {
// 7 parents: alias2z6, alias2z5, alias2z4, alias2z3, alias2z, alias2f, icon2
parent: 'alias2z6',
},
alias3: {
// invalid parent
parent: 'icon3',
},
// Loop
loop1: {
parent: 'loop3',
},
loop2: {
parent: 'loop1',
},
loop3: {
parent: 'loop1',
},
},
categories: {
Loop: ['loop1', 'loop2', 'loop3'],
Icon1: ['icon1'],
Icon2: ['icon2'],
},
height: 24,
});
expect(tree.failed).toEqual(new Set(['alias3', 'icon3', 'loop1', 'loop2', 'loop3']));
expect(tree.visible).toEqual(new Set(['icon1', 'alias2z']));
expect(tree.hidden).toEqual(new Set(['icon2', 'alias2f', 'alias2a']));
expect(tree.visibleAliases).toEqual({
alias2z3: 'alias2z',
alias2z4: 'alias2z',
alias2z5: 'alias2z',
});
expect(tree.hiddenAliases).toEqual({
alias2z6: 'alias2z',
alias2z7: 'alias2z',
});
expect(tree.tags).toEqual([
{
title: 'Icon1',
icons: ['icon1'],
},
{
title: 'Icon2',
icons: ['alias2z'],
},
]);
expect(tree.uncategorised).toEqual([]);
});
});

View File

@ -1,3 +1,4 @@
import { generateIconSetIconsTree } from '../../lib/data/icon-set/lists/icons';
import { removeBadAliases } from '../../lib/data/icon-set/store/validate';
describe('Validating icon set', () => {
@ -55,7 +56,7 @@ describe('Validating icon set', () => {
},
},
};
removeBadAliases(iconSet);
removeBadAliases(iconSet, generateIconSetIconsTree(iconSet));
// Check aliases
expect(Object.keys(iconSet.aliases)).toEqual(['baz', 'baz2', 'baz3', 'baz4', 'baz5', 'baz6', 'bazz5']);