416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { stringTools } from '../pages/tools/string';
|
|
import { imageTools } from '../pages/tools/image';
|
|
import { DefinedTool, ToolCategory, UserType } from './defineTool';
|
|
import { capitalizeFirstLetter } from '@utils/string';
|
|
import { numberTools } from '../pages/tools/number';
|
|
import { videoTools } from '../pages/tools/video';
|
|
import { audioTools } from 'pages/tools/audio';
|
|
import { listTools } from '../pages/tools/list';
|
|
import { Entries } from 'type-fest';
|
|
import { jsonTools } from '../pages/tools/json';
|
|
import { csvTools } from '../pages/tools/csv';
|
|
import { timeTools } from '../pages/tools/time';
|
|
import { IconifyIcon } from '@iconify/react';
|
|
import { pdfTools } from '../pages/tools/pdf';
|
|
import { xmlTools } from '../pages/tools/xml';
|
|
import { convertersTools } from '../pages/tools/converters';
|
|
import { TFunction } from 'i18next';
|
|
import { FullI18nKey, I18nNamespaces } from '../i18n';
|
|
|
|
const toolCategoriesOrder: ToolCategory[] = [
|
|
'image-generic',
|
|
'pdf',
|
|
'string',
|
|
'video',
|
|
'time',
|
|
'audio',
|
|
'json',
|
|
'list',
|
|
'csv',
|
|
'number',
|
|
'png',
|
|
'xml',
|
|
'gif',
|
|
'converters'
|
|
];
|
|
export const tools: DefinedTool[] = [
|
|
...imageTools,
|
|
...stringTools,
|
|
...jsonTools,
|
|
...pdfTools,
|
|
...listTools,
|
|
...csvTools,
|
|
...videoTools,
|
|
...numberTools,
|
|
...timeTools,
|
|
...audioTools,
|
|
...xmlTools,
|
|
...convertersTools
|
|
];
|
|
const categoriesConfig: {
|
|
type: ToolCategory;
|
|
title: FullI18nKey;
|
|
value: FullI18nKey;
|
|
icon: IconifyIcon | string;
|
|
}[] = [
|
|
{
|
|
type: 'string',
|
|
icon: 'solar:text-bold-duotone',
|
|
value: 'translation:categories.string.description',
|
|
title: 'translation:categories.string.title'
|
|
},
|
|
{
|
|
type: 'png',
|
|
icon: 'ph:file-png-thin',
|
|
value: 'translation:categories.png.description',
|
|
title: 'translation:categories.png.title'
|
|
},
|
|
{
|
|
type: 'number',
|
|
icon: 'lsicon:number-filled',
|
|
value: 'translation:categories.number.description',
|
|
title: 'translation:categories.number.title'
|
|
},
|
|
{
|
|
type: 'gif',
|
|
icon: 'material-symbols-light:gif-rounded',
|
|
value: 'translation:categories.gif.description',
|
|
title: 'translation:categories.gif.title'
|
|
},
|
|
{
|
|
type: 'list',
|
|
icon: 'solar:list-bold-duotone',
|
|
value: 'translation:categories.list.description',
|
|
title: 'translation:categories.list.title'
|
|
},
|
|
{
|
|
type: 'json',
|
|
icon: 'lets-icons:json-light',
|
|
value: 'translation:categories.json.description',
|
|
title: 'translation:categories.json.title'
|
|
},
|
|
{
|
|
type: 'csv',
|
|
icon: 'material-symbols-light:csv-outline',
|
|
value: 'translation:categories.csv.description',
|
|
title: 'translation:categories.csv.title'
|
|
},
|
|
{
|
|
type: 'video',
|
|
icon: 'lets-icons:video-light',
|
|
value: 'translation:categories.video.description',
|
|
title: 'translation:categories.video.title'
|
|
},
|
|
{
|
|
type: 'pdf',
|
|
icon: 'tabler:pdf',
|
|
value: 'translation:categories.pdf.description',
|
|
title: 'translation:categories.pdf.title'
|
|
},
|
|
{
|
|
type: 'time',
|
|
icon: 'fluent-mdl2:date-time',
|
|
value: 'translation:categories.time.description',
|
|
title: 'translation:categories.time.title'
|
|
},
|
|
{
|
|
type: 'image-generic',
|
|
icon: 'material-symbols-light:image-outline-rounded',
|
|
value: 'translation:categories.image-generic.description',
|
|
title: 'translation:categories.image-generic.title'
|
|
},
|
|
{
|
|
type: 'audio',
|
|
icon: 'ic:twotone-audiotrack',
|
|
value: 'translation:categories.audio.description',
|
|
title: 'translation:categories.audio.title'
|
|
},
|
|
{
|
|
type: 'xml',
|
|
icon: 'mdi-light:xml',
|
|
value: 'translation:categories.xml.description',
|
|
title: 'translation:categories.xml.title'
|
|
},
|
|
{
|
|
type: 'converters',
|
|
icon: 'streamline-plump:convert-pdf-1',
|
|
value: 'translation:categories.converters.description',
|
|
title: 'translation:categories.converters.title'
|
|
}
|
|
];
|
|
const CATEGORIES_USER_TYPES_MAPPINGS: Partial<Record<ToolCategory, UserType>> =
|
|
{
|
|
xml: 'developers',
|
|
csv: 'developers',
|
|
json: 'developers',
|
|
gif: 'generalUsers',
|
|
png: 'generalUsers',
|
|
'image-generic': 'generalUsers',
|
|
video: 'generalUsers',
|
|
audio: 'generalUsers',
|
|
converters: 'generalUsers'
|
|
};
|
|
// Filter tools by user types
|
|
export const filterToolsByUserTypes = (
|
|
tools: DefinedTool[],
|
|
userTypes: UserType[]
|
|
): DefinedTool[] => {
|
|
if (userTypes.length === 0) return tools;
|
|
|
|
return tools.filter((tool) => {
|
|
if (CATEGORIES_USER_TYPES_MAPPINGS[tool.type]) {
|
|
return userTypes.includes(CATEGORIES_USER_TYPES_MAPPINGS[tool.type]!);
|
|
}
|
|
// If tool has no userTypes defined, show it to all users
|
|
if (!tool.userTypes || tool.userTypes.length === 0) return true;
|
|
|
|
// Check if tool has any of the selected user types
|
|
return tool.userTypes.some((userType) => userTypes.includes(userType));
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns the Levenshtein distance between two strings.
|
|
* @param a - First string.
|
|
* @param b - Second string.
|
|
* @returns Minimum number of single-character edits (insert, delete, substitute).
|
|
*/
|
|
const levenshtein = (a: string, b: string): number => {
|
|
if (a === b) return 0;
|
|
const aLen = a.length;
|
|
const bLen = b.length;
|
|
|
|
if (aLen === 0) return bLen;
|
|
if (bLen === 0) return aLen;
|
|
|
|
const dp: number[][] = Array.from({ length: aLen + 1 }, () =>
|
|
Array<number>(bLen + 1).fill(0)
|
|
);
|
|
|
|
for (let i = 0; i <= aLen; i += 1) dp[i][0] = i;
|
|
for (let j = 0; j <= bLen; j += 1) dp[0][j] = j;
|
|
|
|
for (let i = 1; i <= aLen; i += 1) {
|
|
for (let j = 1; j <= bLen; j += 1) {
|
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
|
|
dp[i][j] = Math.min(
|
|
dp[i - 1][j] + 1,
|
|
dp[i][j - 1] + 1,
|
|
dp[i - 1][j - 1] + cost
|
|
);
|
|
}
|
|
}
|
|
|
|
return dp[aLen][bLen];
|
|
};
|
|
|
|
type SearchField = {
|
|
text: string;
|
|
words: string[];
|
|
weight: number;
|
|
};
|
|
|
|
const splitWords = (text: string): string[] =>
|
|
text.split(/[^a-z0-9]+/g).filter(Boolean);
|
|
|
|
/**
|
|
* Normalizes a string by converting it to lowercase and removing diacritics
|
|
* (accent marks). Useful for locale-insensitive string comparison and search.
|
|
*
|
|
* @param text - The string to normalize
|
|
* @returns The normalized string with lowercase letters and no diacritics
|
|
*/
|
|
const normalizeText = (text: string): string =>
|
|
text
|
|
.toLowerCase()
|
|
.normalize('NFKD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
|
|
const computeToolScore = (
|
|
tool: DefinedTool,
|
|
tokens: string[],
|
|
t: TFunction<I18nNamespaces[]>
|
|
): number => {
|
|
const name = normalizeText(t(tool.name));
|
|
const shortDescription = normalizeText(t(tool.shortDescription));
|
|
const description = normalizeText(t(tool.description));
|
|
|
|
const fields: SearchField[] = [
|
|
{ text: name, words: splitWords(name), weight: 5 },
|
|
{ text: shortDescription, words: splitWords(shortDescription), weight: 3 },
|
|
{ text: description, words: splitWords(description), weight: 2 },
|
|
...(tool.keywords ?? []).map((kw) => {
|
|
const normalized = normalizeText(kw);
|
|
return {
|
|
text: normalized,
|
|
words: splitWords(normalized),
|
|
weight: 4
|
|
};
|
|
})
|
|
];
|
|
|
|
let totalScore = 0;
|
|
|
|
for (const token of tokens) {
|
|
if (!token) continue;
|
|
|
|
let bestForToken = 0;
|
|
|
|
for (const field of fields) {
|
|
let fieldScore = 0;
|
|
|
|
if (field.text.includes(token)) {
|
|
// Base score for substring match
|
|
fieldScore = field.weight * 2;
|
|
|
|
if (field.words.includes(token)) {
|
|
// Boost whole-word matches
|
|
fieldScore += field.weight;
|
|
}
|
|
} else {
|
|
for (const word of field.words) {
|
|
// Quick length check before full distance calculation
|
|
if (Math.abs(word.length - token.length) > 1) continue;
|
|
|
|
const dist = levenshtein(token, word);
|
|
if (dist === 1) {
|
|
fieldScore = Math.max(fieldScore, field.weight);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (fieldScore > bestForToken) {
|
|
bestForToken = fieldScore;
|
|
}
|
|
}
|
|
// If any token fails to match (exact or fuzzy), treat the tool as a non-match.
|
|
if (bestForToken === 0) return 0;
|
|
|
|
totalScore += bestForToken;
|
|
}
|
|
|
|
return totalScore;
|
|
};
|
|
|
|
export const filterTools = (
|
|
tools: DefinedTool[],
|
|
query: string,
|
|
userTypes: UserType[] = [],
|
|
t: TFunction<I18nNamespaces[]>
|
|
): DefinedTool[] => {
|
|
let filteredTools = tools;
|
|
|
|
// First filter by user types
|
|
if (userTypes.length > 0) {
|
|
filteredTools = filterToolsByUserTypes(tools, userTypes);
|
|
}
|
|
|
|
const normalizedQuery = normalizeText(query);
|
|
|
|
// If query is empty after normalization, return all tools (after user-type filtering)
|
|
if (!normalizedQuery) return filteredTools;
|
|
|
|
const rawTokens = normalizedQuery.split(' ').filter(Boolean);
|
|
if (rawTokens.length === 0) return filteredTools;
|
|
|
|
// Expand tokens with simple alpha+digit concatenation variants, e.g. "base" + "64" -> "base64".
|
|
// This, combined with per-tool `keywords`, allows us to support aliases like
|
|
// "base 64" / "base64" / "b64" without requiring every form in every keyword list.
|
|
const tokens: string[] = [...rawTokens];
|
|
|
|
for (let i = 0; i < rawTokens.length - 1; i += 1) {
|
|
const current = rawTokens[i];
|
|
const next = rawTokens[i + 1];
|
|
|
|
if (/^[a-zA-Z]+$/.test(current) && /^\d+$/.test(next)) {
|
|
tokens.push(`${current}${next}`);
|
|
}
|
|
}
|
|
|
|
const scored = filteredTools
|
|
.map((tool) => ({
|
|
tool,
|
|
score: computeToolScore(tool, tokens, t)
|
|
}))
|
|
.filter(({ score }) => score > 0)
|
|
.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
|
|
const aName = t(a.tool.name).toLowerCase();
|
|
const bName = t(b.tool.name).toLowerCase();
|
|
|
|
return aName.localeCompare(bName);
|
|
});
|
|
|
|
return scored.map(({ tool }) => tool);
|
|
};
|
|
|
|
export const getToolsByCategory = (
|
|
userTypes: UserType[] = [],
|
|
t: TFunction<I18nNamespaces[]>
|
|
): {
|
|
title: string;
|
|
rawTitle: string;
|
|
description: string;
|
|
icon: IconifyIcon | string;
|
|
type: ToolCategory;
|
|
example: { title: string; path: string };
|
|
tools: DefinedTool[];
|
|
userTypes: UserType[]; // <-- Add this line
|
|
}[] => {
|
|
const groupedByType: Partial<Record<ToolCategory, DefinedTool[]>> =
|
|
Object.groupBy(tools, ({ type }) => type);
|
|
|
|
return (Object.entries(groupedByType) as Entries<typeof groupedByType>)
|
|
.map(([type, tools]) => {
|
|
const categoryConfig = categoriesConfig.find(
|
|
(config) => config.type === type
|
|
);
|
|
|
|
// Filter tools by user types if specified
|
|
const filteredTools =
|
|
userTypes.length > 0
|
|
? filterToolsByUserTypes(tools ?? [], userTypes)
|
|
: tools ?? [];
|
|
|
|
// Aggregate unique userTypes from all tools in this category
|
|
const aggregatedUserTypes = Array.from(
|
|
new Set((filteredTools ?? []).flatMap((tool) => tool.userTypes ?? []))
|
|
);
|
|
|
|
return {
|
|
rawTitle: categoryConfig?.title
|
|
? t(categoryConfig.title)
|
|
: capitalizeFirstLetter(type),
|
|
title: categoryConfig?.title
|
|
? t(categoryConfig.title)
|
|
: `${capitalizeFirstLetter(type)} Tools`,
|
|
description: categoryConfig?.value ? t(categoryConfig.value) : '',
|
|
type,
|
|
icon: categoryConfig!.icon,
|
|
tools: filteredTools,
|
|
example:
|
|
filteredTools.length > 0
|
|
? { title: filteredTools[0].name, path: filteredTools[0].path }
|
|
: { title: '', path: '' },
|
|
userTypes: aggregatedUserTypes // <-- Add this line
|
|
};
|
|
})
|
|
.filter((category) => category.tools.length > 0)
|
|
.filter((category) =>
|
|
userTypes.length > 0
|
|
? [...category.userTypes, CATEGORIES_USER_TYPES_MAPPINGS[category.type]]
|
|
.filter(Boolean)
|
|
.some((categoryUserType) => userTypes.includes(categoryUserType!))
|
|
: true
|
|
) // Only show categories with tools
|
|
.sort(
|
|
(a, b) =>
|
|
toolCategoriesOrder.indexOf(a.type) -
|
|
toolCategoriesOrder.indexOf(b.type)
|
|
);
|
|
};
|