diff --git a/src/data/icon-set/lists/icons-v2.ts b/src/data/icon-set/lists/icons-v2.ts new file mode 100644 index 0000000..4cee235 --- /dev/null +++ b/src/data/icon-set/lists/icons-v2.ts @@ -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); + 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; +} diff --git a/src/data/icon-set/lists/icons.ts b/src/data/icon-set/lists/icons.ts new file mode 100644 index 0000000..7da4072 --- /dev/null +++ b/src/data/icon-set/lists/icons.ts @@ -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 = new Set(); + const visible: Set = new Set(); + const hidden: Set = new Set(); + const failed: Set = new Set(); + const visibleAliases = Object.create(null) as Record; + const hiddenAliases = Object.create(null) as Record; + + // Generate list of tags for each icon + const tags: IconSetIconsListTag[] = []; + const uncategorised: string[] = []; + + const resolvedTags = Object.create(null) as Record>; + 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 | 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, + }; +} diff --git a/src/data/icon-set/lists/validate.ts b/src/data/icon-set/lists/validate.ts new file mode 100644 index 0000000..0417579 --- /dev/null +++ b/src/data/icon-set/lists/validate.ts @@ -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]; + }); +} diff --git a/src/data/icon-set/store/storage.ts b/src/data/icon-set/store/storage.ts index 5747d43..0063eb2 100644 --- a/src/data/icon-set/store/storage.ts +++ b/src/data/icon-set/store/storage.ts @@ -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 = 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; diff --git a/src/data/icon-set/store/validate.ts b/src/data/icon-set/store/validate.ts deleted file mode 100644 index 0013d15..0000000 --- a/src/data/icon-set/store/validate.ts +++ /dev/null @@ -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 = new Set(); - const failed: Set = 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]; - }); -} diff --git a/src/data/icon-set/utils/get-icons.ts b/src/data/icon-set/utils/get-icons.ts index f11d75f..6d41ef7 100644 --- a/src/data/icon-set/utils/get-icons.ts +++ b/src/data/icon-set/utils/get-icons.ts @@ -13,7 +13,7 @@ export function getIconsToRetrieve( copyTo?: IconifyAliases ): Set { const icons: Set = 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, }); diff --git a/src/http/index.ts b/src/http/index.ts index 4381078..bf8a986 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -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 diff --git a/src/http/responses/collection-v2.ts b/src/http/responses/collection-v2.ts new file mode 100644 index 0000000..02a1e1a --- /dev/null +++ b/src/http/responses/collection-v2.ts @@ -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; + + 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); +} diff --git a/src/http/responses/collections.ts b/src/http/responses/collections.ts index 83423d7..fe90065 100644 --- a/src/http/responses/collections.ts +++ b/src/http/responses/collections.ts @@ -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; diff --git a/src/types/icon-set/extra.ts b/src/types/icon-set/extra.ts new file mode 100644 index 0000000..32305ce --- /dev/null +++ b/src/types/icon-set/extra.ts @@ -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; + + // Hidden icons + hidden: Set; + + // Aliases, pointing to parent icon in either `visible` or `hidden` set + visibleAliases: Record; + hiddenAliases: Record; + + // Failed aliases + failed: Set; + + // 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; + + // List of hidden icons + hidden?: string[]; + + // List of aliases, key = alias, value = parent icon + aliases?: Record; + + // Themes + themes?: IconifyJSON['themes']; + prefixes?: IconifyJSON['prefixes']; + suffixes?: IconifyJSON['suffixes']; + }; + + // Characters, key = character, value = icon name + chars?: Record; +} + +/** + * Extra data generated for each icon set + */ +export interface IconSetExtraData { + v2list: IconSetAPIv2IconsList; +} diff --git a/src/types/icon-set/storage.ts b/src/types/icon-set/storage.ts index 48c8a3c..7855897 100644 --- a/src/types/icon-set/storage.ts +++ b/src/types/icon-set/storage.ts @@ -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[]; tree: SplitDataTree>; + // Icons list + icons: IconSetIconsListIcons; + apiV2IconsCache: IconSetAPIv2IconsList; + // TODO: add properties for search data } diff --git a/tests/icon-set/tree-test.ts b/tests/icon-set/tree-test.ts new file mode 100644 index 0000000..f728f5c --- /dev/null +++ b/tests/icon-set/tree-test.ts @@ -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: '', + }, + baz: { + body: '', + }, + foo: { + body: '', + }, + }, + }); + + 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: '', + }, + bar2: { + body: '', + }, + }, + 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: '', + width: 20, + height: 20, + }, + icon2: { + body: '', + 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([]); + }); +}); diff --git a/tests/icon-set/validate-test.ts b/tests/icon-set/validate-test.ts index 0c22fe6..179ef13 100644 --- a/tests/icon-set/validate-test.ts +++ b/tests/icon-set/validate-test.ts @@ -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']);