Files
dashboard-icons/web/src/lib/utils.ts
2025-09-29 11:01:14 +02:00

237 lines
7.1 KiB
TypeScript

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import type { IconWithName } from "@/types/icons"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatIconName(name: string) {
return name.replace(/-/g, " ")
}
/**
* Calculate Levenshtein distance between two strings
*/
export function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
// Initialize the matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
// Fill the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost, // substitution
)
}
}
return matrix[b.length][a.length]
}
/**
* Calculate similarity score between two strings (0-1)
* Higher score means more similar
*/
export function calculateStringSimilarity(str1: string, str2: string): number {
if (!str1.length || !str2.length) return 0
if (str1 === str2) return 1
const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase())
const maxLength = Math.max(str1.length, str2.length)
return 1 - distance / maxLength
}
/**
* Check if string contains all characters from query in order
* Returns match score (0 if no match)
*/
export function containsCharsInOrder(str: string, query: string): number {
if (!query) return 1
if (!str) return 0
const normalizedStr = str.toLowerCase()
const normalizedQuery = query.toLowerCase()
let strIndex = 0
let queryIndex = 0
while (strIndex < normalizedStr.length && queryIndex < normalizedQuery.length) {
if (normalizedStr[strIndex] === normalizedQuery[queryIndex]) {
queryIndex++
}
strIndex++
}
// If we matched all characters in the query
if (queryIndex === normalizedQuery.length) {
// Calculate a score based on closeness of matches
// Higher score if characters are close together
const matchRatio = normalizedStr.length / (strIndex + 1)
return matchRatio
}
return 0
}
/**
* Advanced fuzzy search with composite scoring and bonuses:
* - Bonus for exact, prefix, substring matches (additive)
* - Penalize weak matches
* - Require all query words to be present somewhere for multi-word queries
* - Returns composite score (0-1+)
*/
export function fuzzySearch(text: string, query: string): number {
if (!query) return 1
if (!text) return 0
const normalizedText = text.toLowerCase()
const normalizedQuery = query.toLowerCase()
let score = 0
// Bonuses for strong matches
if (normalizedText === normalizedQuery) score += 1.0
else if (normalizedText.startsWith(normalizedQuery)) score += 0.85
else if (normalizedText.includes(normalizedQuery)) score += 0.7
// Sequence, similarity, word match
const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
// Multi-word query: require all words to be present somewhere
const textWords = normalizedText.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
let wordMatchCount = 0
for (const queryWord of queryWords) {
for (const textWord of textWords) {
if (
textWord === queryWord ||
textWord.startsWith(queryWord) ||
textWord.includes(queryWord) ||
calculateStringSimilarity(textWord, queryWord) > 0.8 ||
containsCharsInOrder(textWord, queryWord) > 0.5
) {
wordMatchCount++
break
}
}
}
const allWordsPresent = wordMatchCount === queryWords.length
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
// Composite score
score += sequenceScore * 0.1 + similarityScore * 0.1 + wordMatchScore * 0.6
// Penalize if not all words present in multi-word query
if (queryWords.length > 1 && !allWordsPresent) score *= 0.4
// Penalize very weak matches
if (score < 0.5) score *= 0.3
return score
}
/**
* Filter and sort icons using advanced fuzzy search, categories, and sort options
* - Tunable weights for name, alias, category
* - Penalize if only category matches
* - Require all query words to be present in at least one field
*/
export type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
export function filterAndSortIcons({
icons,
query = "",
categories = [],
sort = "relevance",
limit,
}: {
icons: IconWithName[]
query?: string
categories?: string[]
sort?: SortOption
limit?: number
}): IconWithName[] {
const NAME_WEIGHT = 2.0
const ALIAS_WEIGHT = 1.5
const CATEGORY_WEIGHT = 1.0
const CATEGORY_PENALTY = 0.7 // Penalize if only category matches
let filtered = icons
// Filter by categories if any are selected
if (categories.length > 0) {
filtered = filtered.filter(({ data }) =>
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
)
}
if (query.trim()) {
const queryWords = query.toLowerCase().split(/\s+/)
const scored = filtered
.map((icon) => {
const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT
const aliasScore =
icon.data.aliases && icon.data.aliases.length > 0
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * ALIAS_WEIGHT
: 0
const categoryScore =
icon.data.categories && icon.data.categories.length > 0
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) * CATEGORY_WEIGHT
: 0
const maxScore = Math.max(nameScore, aliasScore, categoryScore)
// Penalize if only category matches
const onlyCategoryMatch = categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5
const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore
// Require all query words to be present in at least one field
const allWordsPresent = queryWords.every(
(word) =>
icon.name.toLowerCase().includes(word) ||
icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) ||
icon.data.categories.some((cat) => cat.toLowerCase().includes(word)),
)
return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 }
})
.filter((item) => item.score > 0.7)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.icon.name.localeCompare(b.icon.name)
})
filtered = scored.map((item) => item.icon)
}
// Sorting
if (sort === "alphabetical-asc") {
filtered = filtered.slice().sort((a, b) => a.name.localeCompare(b.name))
} else if (sort === "alphabetical-desc") {
filtered = filtered.slice().sort((a, b) => b.name.localeCompare(a.name))
} else if (sort === "newest") {
filtered = filtered.slice().sort((a, b) => {
const aTime = a.data.update?.timestamp ? new Date(a.data.update.timestamp).getTime() : 0
const bTime = b.data.update?.timestamp ? new Date(b.data.update.timestamp).getTime() : 0
return bTime - aTime
})
} // else: relevance (already sorted by score)
if (limit && filtered.length > limit) {
return filtered.slice(0, limit)
}
return filtered
}